Java

How to use MDC with thread pools

27 September 2026 · 9 min read

How to use MDC with thread pools

In modern software development, maintaining clear and consistent logging is crucial for debugging, monitoring, and auditing applications, especially those built with concurrent execution in mind. The Mapped Diagnostic Context (MDC), often used with logging frameworks like SLF4J and Logback, provides a powerful mechanism to inject contextual information into log messages. However, when you introduce thread pools into the equation, the default behavior of MDC, which relies on ThreadLocal, can lead to lost context. This article will thoroughly explain how to use MDC with thread pools effectively, ensuring your diagnostic information propagates correctly across asynchronous tasks and making your logs invaluable for understanding complex system behaviors.

Understanding MDC and Thread Pools

The Mapped Diagnostic Context (MDC) is a concept within logging frameworks that allows developers to associate contextual information with the current thread. This information, such as a user ID, a session ID, or a unique request ID, is then automatically included in all log messages produced by that thread. It’s incredibly useful for tracing the flow of a single request or operation through a multi-tiered application, providing a consolidated view of its execution path without cluttering every log statement with explicit context parameters.

Thread pools, on the other hand, are a fundamental component of concurrent programming, designed to manage and reuse a collection of worker threads. Instead of creating a new thread for every task, tasks are submitted to an ExecutorService, which then dispatches them to available threads from its pool. This approach significantly reduces the overhead associated with thread creation and destruction, improving application performance and resource utilization. Common implementations include FixedThreadPool, CachedThreadPool, and ScheduledThreadPool.

The core challenge arises because MDC relies on ThreadLocal variables. When a task is submitted to a thread pool, it might be executed by any available thread. If the submitting thread sets an MDC context, that context is tied to the submitting thread’s ThreadLocal. The worker thread from the pool, being a different thread, will have its own independent ThreadLocal storage, which will be empty. Consequently, any log messages produced by the task running in the worker thread will lack the crucial diagnostic information set by the original caller, leading to incomplete and disjointed logs that are difficult to correlate.

The Challenge: Preserving Context Across Threads

The inherent design of ThreadLocal variables means that each thread maintains its own independent copy of a variable. This isolation is generally beneficial, preventing data conflicts between concurrent threads. However, it becomes a hurdle when you need to propagate context, such as MDC data, from a parent thread to tasks executed by different child threads managed by an ExecutorService. The worker threads within a pool are typically long-lived and reused, meaning they don’t inherit the ThreadLocal state of the thread that submitted the task.

Consider a web request where a unique requestId is generated and placed into the MDC. If this request then triggers an asynchronous background task that runs in a thread pool, the worker thread executing that task will not automatically have the requestId in its MDC. This break in context makes it nearly impossible to trace the full lifecycle of the request through the logs. Debugging becomes a nightmare as related log entries appear fragmented across different threads, lacking the common identifier that ties them together.

While InheritableThreadLocal exists and can propagate ThreadLocal values from a parent thread to a newly created child thread, it doesn’t solve the problem for thread pools. Thread pool threads are typically created once and then reused for many different tasks. They don’t re-inherit context from each new submitting thread. Therefore, a more explicit mechanism is required to capture the MDC state from the submitting thread and inject it into the worker thread before the task execution begins.

To effectively use MDC with thread pools, the key is to capture the MDC state from the submitting thread and explicitly transfer it to the worker thread before the task runs. This is commonly achieved by wrapping the original Runnable or Callable task with a custom decorator that handles the MDC context propagation and cleanup, ensuring that each task executes with the correct diagnostic information and that the thread pool’s state remains clean for subsequent tasks. This approach prevents context leakage and ensures log correlation is maintained across asynchronous boundaries.

Strategies for Propagating MDC in Thread Pools

To overcome the limitations of ThreadLocal in pooled environments, several strategies can be employed. The most robust and widely recommended approach involves explicitly capturing and restoring the MDC context around task execution. This ensures that the diagnostic information is available when and where it’s needed, without interfering with other tasks or leaking context.

Manual Context Copying with Decorators

The most common and flexible method is to wrap your Runnable or Callable tasks with a custom decorator. This decorator is responsible for saving the current MDC context of the submitting thread, then applying it to the worker thread before the task executes, and finally cleaning it up after the task completes. This pattern ensures isolation and proper context management.

Here’s how this strategy generally works:

  1. Capture Context: Before submitting a task, the decorator captures the current MDC state (usually a Map<String, String>) from the calling thread using MDC.getCopyOfContextMap().
  2. Wrap Task: It wraps the original Runnable or Callable with a new implementation that carries this captured context.
  3. Restore Context: When the wrapped task begins execution in a thread pool worker thread, the decorator first sets the captured MDC context onto the worker thread using MDC.setContextMap(capturedContext).
  4. Execute Task: The original task’s run() or call() method is then executed.
  5. Clear Context: After the original task completes (whether successfully or with an exception), the decorator clears the MDC from the worker thread using MDC.clear(). This crucial step prevents context from leaking to subsequent tasks that might reuse the same worker thread.

This method provides precise control over the logging context and is highly reliable for task execution in asynchronous processing scenarios. It’s also easily adaptable to various ExecutorService configurations. For more insights into maintaining robust logging practices, you can explore best practices for robust logging.

Framework-Specific Solutions

Many modern frameworks provide built-in mechanisms or extensions to simplify MDC propagation Question & Answer :

In our software we extensively use MDC to track things like session IDs and user names for web requests. This works fine while running in the original thread.

However, there’s a lot of things that need to be processed in the background. For that we use the java.concurrent.ThreadPoolExecutor and java.util.Timer classes along with some self-rolled async execution services. All these services manage their own thread pool.

This is what Logback’s manual has to say about using MDC in such an environment:

A copy of the mapped diagnostic context can not always be inherited by worker threads from the initiating thread. This is the case when java.util.concurrent.Executors is used for thread management. For instance, newCachedThreadPool method creates a ThreadPoolExecutor and like other thread pooling code, it has intricate thread creation logic.

In such cases, it is recommended that MDC.getCopyOfContextMap() is invoked on the original (master) thread before submitting a task to the executor. When the task runs, as its first action, it should invoke MDC.setContextMapValues() to associate the stored copy of the original MDC values with the new Executor managed thread.

This would be fine, but it is a very easy to forget adding those calls, and there is no easy way to recognize the problem until it is too late. The only sign with Log4j is that you get missing MDC info in the logs, and with Logback you get stale MDC info (since the thread in the tread pool inherits its MDC from the first task that was ran on it). Both are serious problems in a production system.

I don’t see our situation special in any way, yet I could not find much about this problem on the web. Apparently, this is not something that many people bump up against, so there must be a way to avoid it. What are we doing wrong here?

Yes, this is a common problem I’ve run into as well. There are a few workarounds (like manually setting it, as described), but ideally you want a solution that

  • Sets the MDC consistently;
  • Avoids tacit bugs where the MDC is incorrect but you don’t know it; and
  • Minimizes changes to how you use thread pools (e.g. subclassing Callable with MyCallable everywhere, or similar ugliness).

Here’s a solution that I use that meets these three needs. Code should be self-explanatory.

(As a side note, this executor can be created and fed to Guava’s MoreExecutors.listeningDecorator(), if you use Guava’s ListanableFuture.)

import org.slf4j.MDC; import java.util.Map; import java.util.concurrent.*; /** * A SLF4J MDC-compatible {@link ThreadPoolExecutor}. * <p/> * In general, MDC is used to store diagnostic information (e.g. a user's session id) in per-thread variables, to facilitate * logging. However, although MDC data is passed to thread children, this doesn't work when threads are reused in a * thread pool. This is a drop-in replacement for {@link ThreadPoolExecutor} sets MDC data before each task appropriately. * <p/> * Created by jlevy. * Date: 6/14/13 */ public class MdcThreadPoolExecutor extends ThreadPoolExecutor { final private boolean useFixedContext; final private Map<String, Object> fixedContext; /** * Pool where task threads take MDC from the submitting thread. */ public static MdcThreadPoolExecutor newWithInheritedMdc(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue) { return new MdcThreadPoolExecutor(null, corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue); } /** * Pool where task threads take fixed MDC from the thread that creates the pool. */ @SuppressWarnings("unchecked") public static MdcThreadPoolExecutor newWithCurrentMdc(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue) { return new MdcThreadPoolExecutor(MDC.getCopyOfContextMap(), corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue); } /** * Pool where task threads always have a specified, fixed MDC. */ public static MdcThreadPoolExecutor newWithFixedMdc(Map<String, Object> fixedContext, int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue) { return new MdcThreadPoolExecutor(fixedContext, corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue); } private MdcThreadPoolExecutor(Map<String, Object> fixedContext, int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue) { super(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue); this.fixedContext = fixedContext; useFixedContext = (fixedContext != null); } @SuppressWarnings("unchecked") private Map<String, Object> getContextForTask() { return useFixedContext ? fixedContext : MDC.getCopyOfContextMap(); } /** * All executions will have MDC injected. {@code ThreadPoolExecutor}'s submission methods ({@code submit()} etc.) * all delegate to this. */ @Override public void execute(Runnable command) { super.execute(wrap(command, getContextForTask())); } public static Runnable wrap(final Runnable runnable, final Map<String, Object> context) { return new Runnable() { @Override public void run() { Map previous = MDC.getCopyOfContextMap(); if (context == null) { MDC.clear(); } else { MDC.setContextMap(context); } try { runnable.run(); } finally { if (previous == null) { MDC.clear(); } else { MDC.setContextMap(previous); } } } }; } }