Skip to main content
Java intermediate Lesson 45 of 58

Logging in Java with SLF4J and Logback

Learn how to add production-grade logging to Java applications using SLF4J as the facade and Logback as the implementation.

Every production Java application needs structured, configurable logging. System.out.println has no level filtering, no timestamps, no file rotation, and no way to silence specific components. A logging framework solves all of these. SLF4J provides a standard API that your code depends on; Logback is the most popular implementation and the default in Spring Boot. This separation means you can switch the backend without touching a single log call.

Setup

SLF4J and Logback are two separate dependencies — the API and the implementation. In Spring Boot projects, spring-boot-starter already includes both, so you only need this for standalone Java applications.

Maven:

<!-- SLF4J API — the facade your code calls -->
<dependency>
    <groupId>org.slf4j</groupId>
    <artifactId>slf4j-api</artifactId>
    <version>2.0.12</version>
</dependency>

<!-- Logback — the implementation that actually writes logs -->
<dependency>
    <groupId>ch.qos.logback</groupId>
    <artifactId>logback-classic</artifactId>
    <version>1.5.3</version>
</dependency>

If you use Spring Boot, spring-boot-starter already includes SLF4J + Logback — no extra dependency needed.

Basic Usage

One logger per class is the standard convention. Using the class as the logger name lets Logback configuration target specific packages and classes precisely. The {} placeholder syntax is critical: the string is only built when the log level is enabled, avoiding wasted string concatenation in hot paths.

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

public class UserService {

    // One logger per class — the class name becomes the logger's identity
    private static final Logger log = LoggerFactory.getLogger(UserService.class);

    public User createUser(String name, String email) {
        log.debug("Creating user: name={}, email={}", name, email);

        if (name == null || name.isBlank()) {
            log.warn("Attempted to create user with blank name");
            throw new IllegalArgumentException("Name cannot be blank");
        }

        User user = userRepository.save(new User(name, email));
        log.info("User created: id={}, email={}", user.getId(), email);
        return user;
    }

    public void deleteUser(long id) {
        try {
            userRepository.delete(id);
            log.info("User deleted: id={}", id);
        } catch (Exception e) {
            // Pass the exception as the last argument — Logback appends the full stack trace
            log.error("Failed to delete user id={}", id, e);
        }
    }
}

Log Levels

Each level has a specific meaning. Choosing the wrong level floods logs with noise (too verbose) or hides important events (too quiet). In production, run at INFO or WARNDEBUG and TRACE are for development and diagnosis only.

log.trace("Entering method with args: {}", args);       // finest detail — method entry/exit
log.debug("Query executed in {}ms", duration);          // diagnostic info for development
log.info("Server started on port {}", port);            // normal operational events
log.warn("Retry attempt {} of {} for {}", n, max, url); // unexpected but handled
log.error("Payment processing failed for order {}", id, exception); // failures needing attention

Never concatenate strings in log calls — use {} placeholders:

// BAD — string is built even when debug level is disabled (wasted CPU on every call)
log.debug("User: " + user.getName() + " at " + System.currentTimeMillis());

// GOOD — string is only built if the debug level is actually enabled
log.debug("User: {} at {}", user.getName(), System.currentTimeMillis());

Logback Configuration

Without a configuration file, Logback logs everything at DEBUG to the console. A logback.xml file gives you full control over which appenders (console, file, JSON) are active, which logger names are at which levels, and how log lines are formatted.

Create src/main/resources/logback.xml:

<?xml version="1.0" encoding="UTF-8"?>
<configuration>

    <!-- Console appender — immediate output, good for development -->
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
        </encoder>
    </appender>

    <!-- Rolling file appender — rotates daily, keeps 30 days, caps at 1GB total -->
    <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <file>logs/app.log</file>
        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
            <fileNamePattern>logs/app.%d{yyyy-MM-dd}.log.gz</fileNamePattern>
            <maxHistory>30</maxHistory>
            <totalSizeCap>1GB</totalSizeCap>
        </rollingPolicy>
        <encoder>
            <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
        </encoder>
    </appender>

    <!-- Suppress noisy third-party libraries -->
    <logger name="org.hibernate" level="WARN"/>
    <logger name="com.zaxxer.hikari" level="WARN"/>

    <!-- Your application code — verbose for easier debugging -->
    <logger name="com.example" level="DEBUG"/>

    <!-- Root — everything else logs at INFO to both appenders -->
    <root level="INFO">
        <appender-ref ref="CONSOLE"/>
        <appender-ref ref="FILE"/>
    </root>

</configuration>

Pattern Format Tokens

TokenMeaning
%d{pattern}Timestamp (Java DateTimeFormatter pattern)
%threadThread name
%-5levelLog level, left-padded to 5 chars
%logger{36}Logger name, abbreviated to 36 chars
%msgThe log message
%nNewline
%exException stack trace

Environment-Specific Config

Hard-coding the log level in logback.xml means you need different files per environment. A better approach is to read the level from an environment variable, with a sensible fallback. This lets ops teams change log verbosity at deployment time without touching the artifact.

<!-- logback.xml -->
<configuration>
    <!-- Read LOG_LEVEL from environment; fall back to INFO if not set -->
    <property name="LOG_LEVEL" value="${LOG_LEVEL:-INFO}"/>

    <root level="${LOG_LEVEL}">
        <appender-ref ref="CONSOLE"/>
    </root>
</configuration>
# Override at startup without changing any config file
java -DLOG_LEVEL=DEBUG -jar app.jar
LOG_LEVEL=DEBUG java -jar app.jar

Structured JSON Logging (Production)

Human-readable log lines are convenient for local development but are hard to query at scale. Log aggregation systems like the ELK stack, Datadog, and Splunk expect JSON so they can index individual fields. The logstash-logback-encoder library adds a JSON encoder that Logback uses in place of the pattern encoder.

<dependency>
    <groupId>net.logstash.logback</groupId>
    <artifactId>logstash-logback-encoder</artifactId>
    <version>7.4</version>
</dependency>
<appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
    <!-- LogstashEncoder replaces the pattern encoder with structured JSON output -->
    <encoder class="net.logstash.logback.encoder.LogstashEncoder"/>
</appender>

Output:

{"@timestamp":"2024-06-15T14:30:00.123Z","level":"INFO","logger":"com.example.UserService","message":"User created: id=42, [email protected]","thread":"main"}

MDC — Mapped Diagnostic Context

When a single request touches multiple services and classes, correlating log lines by request becomes difficult. MDC solves this by attaching key-value pairs to the current thread that Logback automatically includes in every log line that thread produces. The request ID or user ID in every log line is how you trace a single request across a complex system.

import org.slf4j.MDC;

// In a web request filter or interceptor — runs before every request
public class RequestLoggingFilter implements Filter {
    @Override
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
            throws IOException, ServletException {
        String requestId = UUID.randomUUID().toString().substring(0, 8);
        MDC.put("requestId", requestId);
        MDC.put("userId", getUserIdFromSession(req));
        try {
            chain.doFilter(req, res);
        } finally {
            MDC.clear(); // always clear — MDC values persist on thread pool threads
        }
    }
}

Add %X{requestId} to your pattern to include the MDC value in every line:

<pattern>%d{HH:mm:ss} [%X{requestId}] %-5level %logger - %msg%n</pattern>

Now every log line in that request automatically shows the request ID — no need to pass it through every method call:

14:30:00 [a1b2c3d4] INFO  UserService - User created: id=42
14:30:00 [a1b2c3d4] INFO  OrderService - Order placed: orderId=ORD-001

Best Practices

// Guard expensive parameter computation — isDebugEnabled() is a fast check
if (log.isDebugEnabled()) {
    log.debug("Full object state: {}", expensiveToString());
}

// Include enough context in error logs to diagnose without reproducing the bug
log.error("Failed to process payment for orderId={}, customerId={}, amount={}",
    order.getId(), customer.getId(), order.getTotal(), exception);

// Never log sensitive data — it ends up in log files, monitoring systems, and backups
// WRONG:
log.info("User logged in: email={}, password={}", email, password);
// RIGHT:
log.info("User logged in: email={}", email);

// Use a consistent field format so logs are easily searchable by key
log.info("action=user_created userId={} email={}", user.getId(), user.getEmail());

Frequently Asked Questions

Why use SLF4J instead of logging directly with Logback?
SLF4J is a logging facade — your code depends on the API, not the implementation. If you later want to switch from Logback to Log4j2 or java.util.logging, you change one dependency, not every log call in your codebase. Libraries should always use SLF4J so the application controls which backend runs.
What is the difference between log levels?
TRACE is the finest detail (every method entry/exit). DEBUG is diagnostic info useful during development. INFO is normal operational events (server started, user logged in). WARN is something unexpected that the application handled. ERROR is a failure that needs attention. In production, run at INFO or WARN to avoid log volume overhead.
Should I use System.out.println for logging?
No. println has no level filtering, no timestamp, no context, no file rotation, and no async output. A logging framework gives you all of those for free. Replace every System.out and System.err with a proper logger.