Java Logging Best Practices

Logging is the most-used feature of any Java application and one of the most consistently mishandled. Bad logging — too much, too little, unstructured, expensive — costs production time when nobody can debug an issue or when log volume drives infrastructure cost.

This page covers the working patterns for Java logging at scale.

Use SLF4J

SLF4J is the de facto standard logging facade for Java. Library code should depend on slf4j-api; the application picks the implementation (Logback, Log4j2).

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

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

    public void process(Order order) {
        log.info("Processing order {}", order.id());
    }
}

Key points:

Parameterized messages

// Wrong: string concat happens always
log.debug("Processing order " + order.id());

// Right: format only happens if debug is enabled
log.debug("Processing order {}", order.id());

The parameterized form skips the format work when the level is disabled. For DEBUG/TRACE messages, this matters at scale.

Levels that actually matter

The SLF4J levels: TRACE, DEBUG, INFO, WARN, ERROR.

The most common failure: logging too much at INFO level. INFO should be sparse — major events, not every step.

Structured logging

For modern observability, structured logs (JSON-formatted with key-value fields) beat free-form text. They're queryable; they integrate with log aggregation systems.

With Logback + JSON encoder:

log.atInfo()
    .addKeyValue("orderId", order.id())
    .addKeyValue("amount", order.amount())
    .log("Order processed");

The output is JSON; tools like Datadog, Splunk, ELK can index by key.

For typical use, MDC (Mapped Diagnostic Context) provides per-thread context that's added to every log message:

MDC.put("requestId", requestId);
try {
    // work
} finally {
    MDC.clear();
}

Combined with structured logging, every log message in a request is correlated by requestId. Essential for distributed tracing.

Logging exceptions correctly

try {
    process(order);
} catch (ProcessingException e) {
    log.error("Failed to process order {}", order.id(), e);
}

The exception parameter goes last; SLF4J recognizes it and logs the full stack trace. Anti-patterns:

What to log

Always

Sometimes

Almost never

Logging PII / secrets

Never log:

Use redaction filters or structured logging that explicitly marks sensitive fields. Logs persist; sensitive data in logs becomes a compliance liability.

Performance considerations

Async logging

For high-throughput applications, synchronous logging becomes a bottleneck. Logback's AsyncAppender queues messages on a separate thread.

The trade-off: messages may be lost on crash; the async queue can fill up. For most production applications, async logging is the right default.

Disabling expensive evaluations

Even with parameterized logging, building the parameter can be expensive:

// expensive computation happens always
log.debug("Order detail: {}", expensiveSerialization(order));

Guard with isDebugEnabled():

if (log.isDebugEnabled()) {
    log.debug("Order detail: {}", expensiveSerialization(order));
}

Or use the lambda-based logging API (modern SLF4J):

log.atDebug().log(() -> "Order detail: " + expensiveSerialization(order));

Configuration

Externalize log configuration. logback.xml (or logback-spring.xml in Spring Boot) for Logback. Settings should be:

Common failure patterns

Further Reading