Java

Logging in Java: SLF4J, Logback, and Best Practices

Executive Summary

Logging in Java starts with SLF4J, a facade. Your code depends on the org.slf4j Logger interface and calls log.info with parameterized messages, log.info(“fetched {} in {}ms”, uri, millis). As a result, levels, formatting cost, and output routing all become deployment decisions instead of code decisions. Logback is the default engine. Its logback.xml declares appenders, where lines go: console, file, or network. It also declares encoders, the format pattern, with rolling policies for file rotation. You can reconfigure the whole thing without touching code.

The levels are a contract. Trace and debug serve developers, and info tells the operational story. Warn flags recoverable problems worth watching, while error marks failures needing attention. The discipline is severity honesty. After all, a log full of errors for expected validation failures trains the team to ignore errors. Exceptions log with the throwable as the last argument, log.error(“charge failed”, ex), which attaches the full stack trace. Also, the exceptions article’s rule applies: never log and swallow, and rarely log and rethrow, which double-reports the same failure. MDC, Logback’s mapped diagnostic context, attaches request IDs to every line in a thread’s scope. In turn, that correlation makes multi-service incidents tractable. Finally, a few practices separate usable logs from noise. Use parameterized messages instead of concatenation, and never log secrets. Also, avoid logging in tight loops, and use levels that mean what they say.

Why println Fails in Production

// WRONG: println is a learning tool wearing a production costume
System.out.println("Order failed for order " + orderId
    + " reason " + reason + " user " + user);
// no timestamp, no level, no logger name, no routing, no way to turn it off,
// string built EVEN IF you would never print it, and every line lands in
// one undifferentiated stream nobody can query

// RIGHT: a leveled, parameterized line through a named logger
private static final Logger log = LoggerFactory.getLogger(OrderService.class);
log.warn("order {} failed for user {}: {}", orderId, user, reason);
// 2026-11-28 03:12:44 WARN  c.i.catalog.OrderService - order 4011 failed
// for user 77: insufficient stock

The delta is everything operational. In contrast, the right line knows when it happened, what severity it carries, and which class produced it. It also knows who decides where it goes: you at configuration time, not the code at compile time. The {} placeholders are not decoration. Instead, they are lazy formatting: SLF4J builds the string only if the line will actually be emitted. So a debug line behind a disabled level costs one method call instead of a concatenation. The profiling article’s allocation lens makes that a real difference in hot paths.

SLF4J: The Facade Your Code Calls

One design decision makes the whole ecosystem work. Specifically, your code binds to an interface, and the implementation arrives as a JAR on the classpath. That indirection is why the Maven article’s SLF4J conflict story existed, with two bindings fighting. It is also why this design wins. For instance, the application jar decides Logback today, and a test can swap the implementation without touching code.

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

class OrderService {
    private static final Logger log = LoggerFactory.getLogger(OrderService.class);

    void place(Order order) {
        log.info("placing order {} for user {}", order.id(), order.userId());

        log.debug("order details: {}", order);        // off in prod: near-zero cost

        if (!order.isPayable()) {
            log.warn("order {} rejected: payment method expired", order.id());
        }
    }
}

Use one declaration per class, with the class itself as the logger name. Then the logger name in every output line is how you navigate a log file: grep for the class, find the behavior. The level methods, trace, debug, info, warn, error, are five verbs, and the configuration decides which ones reach which destination.

Logback: The Engine and logback.xml

Logback answers the three production questions, where do lines go, in what format, and what happens when files fill up. A minimal, honest logback.xml:

<configuration>

  <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
    <encoder>
      <pattern>%d{yyyy-MM-dd HH:mm:ss} %-5level %logger{36} - %msg%n</pattern>
    </encoder>
  </appender>

  <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
    <file>logs/app.log</file>
    <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
      <fileNamePattern>logs/app.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
      <maxFileSize>100MB</maxFileSize>
      <maxHistory>14</maxHistory>
    </rollingPolicy>
    <encoder>
      <pattern>%d{yyyy-MM-dd HH:mm:ss} %-5level %logger{36} - %msg%n</pattern>
    </encoder>
  </appender>

  <logger name="in.imraan" level="DEBUG"/>       <!-- your code: verbose -->
  <root level="INFO">                          <!-- everything else: calm -->
    <appender-ref ref="CONSOLE"/>
    <appender-ref ref="FILE"/>
  </root>
</configuration>

Read the structure once, and every Logback file you ever meet is readable. First, appenders are destinations, and encoders are formats. Next, rolling policies answer disk exhaustion with compressed, dated, capped history. Finally, loggers set levels per package, so your code can run DEBUG while a chatty library runs WARN. This file ships in src/main/resources, changes with deployment, and never requires recompiling. That is the entire point of the facade.

Levels: A Contract With Your Future Self

A level is a promise about who needs the line, and the contract only works if the code keeps it:

Level The promise Examples Production default
TRACE / DEBUG Developers, this request Decision values, cache hits, loop internals Off, enabled temporarily per package
INFO The operational story Startup, config loaded, order placed, shutdown On
WARN Recoverable, worth watching Retry succeeded, degraded path taken, input rejected On, alerting candidates
ERROR Something needs a human eventually A failed operation with no recovery, an unexpected exception On, paging candidates

Two lines of discipline keep the contract alive. First, expected outcomes are not errors. For example, a user submitting an invalid form is business as usual, WARN at most. Yet code that logs ERROR for every 400 response trains the on-call engineer to ignore errors. At that point, logging stops protecting anything. Second, quiet success is not nothing. INFO should narrate the important state transitions so the log reads like a story of what the system did, not only a list of its complaints.

Exceptions: The Throwable Argument

The single most valuable logging rule in Java: pass the exception as the last argument, and the stack trace attaches itself.

try {
    return gateway.charge(token, total);
} catch (GatewayTimeoutException ex) {
    // WRONG: the exception's message only, no stack trace, no cause chain
    log.error("charge failed: " + ex.getMessage());
    throw new OrderFailedException(order.id(), ex);
}

// RIGHT: message + throwable: full stack trace, full cause chain
} catch (GatewayTimeoutException ex) {
    log.error("charge for order {} timed out", order.id(), ex);   // note the 3rd arg
    throw new OrderFailedException(order.id(), ex);
}

The delta is the difference between “something failed” and “here is where, and why”. With the throwable attached, the log line carries the entire stack and the chained causes. That is the evidence the exceptions article’s wrapping discipline preserved. The companion rules complete the discipline. Do not log and swallow, the empty catch that hides the failure. Also, do not log and rethrow, which double-reports every failure: once here and once at the boundary that handles it. As a result, logs fill with the same stack twice. Log the exception where it is handled or translated, and let the rethrow carry the story to wherever it lands.

MDC: Correlation Across Lines and Services

The last piece turns scattered lines into a narrative: MDC, a per-thread map Logback stamps onto every line the thread logs. The standard use is the request ID. You set it at the edge of an HTTP request, and it attaches to every line that request produces. Then one more pattern token prints it:

// at the request's edge, a filter or the first line of handling:
var requestId = UUID.randomUUID().toString();
MDC.put("requestId", requestId);
try {
    handle(request);          // every log line inside carries the ID
} finally {
    MDC.remove("requestId");  // thread pools REUSE threads: clean up, always
}

// pattern token: %X{requestId}
// 03:12:44 WARN c.i.catalog.OrderService [req 8f3a] order 4011 rejected
// 03:12:44 WARN c.i.catalog.Payment     [req 8f3a] gateway timeout
// two lines, one story: the same request, traceable end to end

The finally block is not optional, because pooled threads outlive requests. Therefore, a missing remove means the next request on that thread wears the previous one’s ID. That corrupts exactly the correlation the MDC exists to provide. Virtual threads make this cheaper than ever, one thread per request, but the cleanup discipline stays the same.

How Real Systems Do This

Production Java has standardized on this exact stack: SLF4J in code and Logback or Log4j2 behind it. In addition, teams ship JSON-formatted lines to aggregators, such as ELK, Loki, or a cloud offering. There, search is indexed and teams build dashboards. MDC correlation IDs are table stakes, and log levels per package are the operational dial. For example, you enable DEBUG for one troubled package at 2 p.m. and turn it off once you have your answer. The console and file appenders from this article are the development and small-deployment forms. Meanwhile, the aggregators are the scaled form of the same routing idea.

The incident that made me an MDC evangelist was a six-hour outage that should have taken thirty minutes. A checkout flow spanned four services: an edge, a catalog, a pricing service, and a payment service. When payments started failing, every team had its own logs with its own request identifiers, or none at all. Correlating “this user’s failed checkout” across services meant matching timestamps by hand across four log stores. We also had to guess which of the ten concurrent requests was the customer’s. Meanwhile, the root cause, a pricing service timeout, sat in plain sight for hours, because nobody could see it belonged to our request.

The fix cost a day. We set one generated request ID at the edge and propagated it on an HTTP header. Then every service stamped it into MDC and printed it in every line. The next incident of the same kind took twenty minutes to diagnose. I now treat correlation IDs as the highest-return line of logging code a service can have, so I add them on day one. Retrofitting them into an outage is the most expensive possible way to learn this lesson.

Decision Framework

  1. Is the line for you, debugging this feature? DEBUG, parameterized, and off in production by default.
  2. Is it part of the operational story, a state transition worth recording? INFO, and the message should read as narration.
  3. Is it recoverable but worth watching, a retry, a degraded path, rejected input? WARN, with the reason and the identifier.
  4. Does something need a human, a failed operation with no recovery? ERROR, with the throwable attached.
  5. Is there an exception in your hand? Pass it as the last argument. Then log it at the point of handling or translation, never swallow, and rarely rethrow.
  6. Are requests processed by threads? An MDC correlation ID at the edge, with cleanup in finally, before the first incident asks for it.
  7. Is the log line in a hot path? Parameterized placeholders, no concatenation, and consider whether DEBUG volume itself is the profiling article’s next finding.

When NOT to Use This

  • Do not log secrets. Passwords, tokens, card numbers, and personal data have no place in logs. After all, systems copy, ship, and retain logs far beyond your code’s reach.
  • Do not log inside tight loops. For instance, a million DEBUG lines per request is the profiling article’s allocation story again, and the guard is the level check, not the prayer.
  • Do not build your own logging framework. After all, levels, rolling, async appenders, and binding are solved problems, and the facade exists precisely so you never hand-roll this.
  • Do not log and rethrow. Instead, pick the logging point, the handler or the translator, and let the exception travel silently the rest of the way.
  • Do not use logging as a substitute for the debugging article’s tools. In fact, breakpoints and thread dumps answer questions logs were never shaped to ask.

Common Mistakes

  • Concatenation instead of placeholders: the code builds the string even when the line is disabled. So hot paths pay the concatenation tax on every call.
  • Logging ex.getMessage() without the throwable: no stack trace, no cause chain, and the line says what broke without where or why.
  • ERROR for expected outcomes: validation failures and client mistakes are WARN at most. Otherwise, an error channel that cries wolf gets muted exactly when it is right.
  • Log and swallow: the empty catch with a log line converts a failure into a rumor. Besides, the exceptions article’s rule was already that this never happens.
  • Missing MDC cleanup on pooled threads: the next request inherits a stale correlation ID, and the trace lies.
  • Logging whole request or response bodies: secrets, personal data, and volume, three problems in one habit. Worse, you pay the redaction cost forever.
  • Configuring appenders in code: routing belongs in logback.xml, where deployment can change it. In contrast, hardcoded appender setup re-implements the facade’s one job.

Key Takeaways

  • For logging in Java, SLF4J is the facade your code calls, and Logback is the engine behind it. Moreover, the binding between them is a classpath decision, not a code decision.
  • Parameterized messages, log.info(“id {}”, id), are lazy, cheap when disabled, and readable, and concatenation has no remaining excuse.
  • The logback.xml file owns routing. It holds appenders for destinations, encoders for formats, rolling policies for disk, and levels per package for the operational dial.
  • Levels are a contract: DEBUG for developers, INFO for the story, WARN for watch-worthy, ERROR for human-needed. Above all, honesty keeps the contract alive.
  • Exceptions log as the last argument, with full stack trace and cause chain. Log them where you handle or translate them, never swallow them, and rarely double-report.
  • MDC correlation IDs turn scattered lines into a narrative, and the finally cleanup keeps pooled threads honest.
  • Secrets never enter logs, hot paths stay quiet, and the facade means you never hand-roll routing again.

FAQ

What is SLF4J in Java?

Simple Logging Facade for Java, the standard API for logging in Java. It is the interface your code calls: Logger, with leveled, parameterized methods. Meanwhile, the actual implementation, Logback, Log4j2, or java.util.logging through a bridge, arrives as a classpath decision. It decouples logging code from logging configuration.

What is Logback in Java?

The default SLF4J implementation. Its logback.xml configures appenders for destinations, encoders for formats, rolling policies for file rotation, and levels per package. Logback succeeds Log4j 1.x, comes from the same authors, and ships as the standard engine behind SLF4J.

What log level should I use in Java?

Use DEBUG for developers tracing a feature and INFO for the operational story of state transitions. Use WARN for recoverable problems worth watching, like retries and rejected input. Finally, use ERROR for failures that need a human. Expected outcomes are never errors, and level honesty keeps the alerts meaningful.

How do I log exceptions with SLF4J?

Pass the throwable as the last argument: log.error(“charge for order {} failed”, orderId, ex) attaches the full stack trace and cause chain. Log at the point of handling or translation, and never log and swallow. Also, avoid log and rethrow, which reports the same failure twice.

What is MDC in Java logging?

Mapped Diagnostic Context: a per-thread map the engine stamps onto every log line, used mainly for correlation IDs. Set the request ID at the edge, with MDC.put in a try and MDC.remove in finally. Pooled threads outlive requests, and stale IDs corrupt the trace.

Conclusion

With logging in Java in place, your programs can now narrate their own operation. Leveled lines flow through SLF4J, and Logback routes and rotates them. Exceptions carry their full evidence, and requests stay correlated across every line they touch. Logging is the first half of production observability, and the suite from the testing articles is the second.

The next article is the skill that logging serves: debugging. It works in the IDE with breakpoints and watches, against a live thread dump, and remotely against a production JVM. Debugging is the systematic hunt from symptom to cause through the evidence both halves produce.

Write every line for the engineer at 3 a.m. with no context. You already know who that is.

Last updated on 17 September 2026.

Share this article

Leave a Reply

Your email address will not be published. Required fields are marked *