Skip to main content
Java · lesson 45 of 66
Open the Java page →
Java intermediate Lesson 45 of 66

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.

By SkillByExample · Updated

System.out.println has no levels, no timestamps, no way to silence one noisy class, and no file output. A logging framework adds all of that. SLF4J is the API your code calls; Logback is the implementation that formats and writes the lines. Everything below was run on JDK 21 with real jars from Maven Central, using the source launcher with the jars on the classpath (java -cp "lib/*:." File.java; on Windows the separator is ;).

Getting the jars

Declare the API and one backend. In a normal Maven or Gradle build these are just dependencies; here Maven copies them into lib/ so single-file examples can use them.

<!-- File: pom.xml -->
<project xmlns="http://maven.apache.org/POM/4.0.0">
  <modelVersion>4.0.0</modelVersion>
  <groupId>com.example</groupId>
  <artifactId>logging-demo</artifactId>
  <version>1.0</version>
  <dependencies>
    <dependency>
      <groupId>org.slf4j</groupId>
      <artifactId>slf4j-api</artifactId>
      <version>2.0.17</version>
    </dependency>
    <dependency>
      <groupId>ch.qos.logback</groupId>
      <artifactId>logback-classic</artifactId>
      <version>1.5.18</version>
    </dependency>
    <dependency>
      <groupId>net.logstash.logback</groupId>
      <artifactId>logstash-logback-encoder</artifactId>
      <version>8.1</version>
    </dependency>
  </dependencies>
</project>
$ mvn -q dependency:copy-dependencies -DoutputDirectory=lib
$ ls lib
jackson-annotations-2.18.3.jar
jackson-core-2.18.3.jar
jackson-databind-2.18.3.jar
logback-classic-1.5.18.jar
logback-core-1.5.18.jar
logstash-logback-encoder-8.1.jar
slf4j-api-2.0.17.jar

logback-classic pulled in logback-core; the JSON encoder (used later) pulled in Jackson. Spring Boot’s starters already include SLF4J and Logback, so there you add nothing.

First log lines

One Logger per class, named after the class. Without any configuration file, Logback logs everything from DEBUG up to the console.

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

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

    public static void main(String[] args) {
        log.trace("very fine detail");
        log.debug("loaded {} settings", 12);
        log.info("server started on port {}", 8080);
        log.warn("retry {} of {} for {}", 2, 3, "https://api.example.com");
        log.error("payment failed for order {}", "ORD-7");
    }
}
$ java -cp "lib/*" FirstLog.java
16:02:12.389 [main] DEBUG FirstLog -- loaded 12 settings
16:02:12.393 [main] INFO FirstLog -- server started on port 8080
16:02:12.394 [main] WARN FirstLog -- retry 2 of 3 for https://api.example.com
16:02:12.394 [main] ERROR FirstLog -- payment failed for order ORD-7

The timestamps are the run’s real clock, so yours will differ. trace produced nothing because the default threshold is DEBUG. Each {} is filled with the next argument, in order.

No backend: the silent failure

The API alone compiles and runs — and logs nothing.

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

public class NoBackend {
    public static void main(String[] args) {
        Logger log = LoggerFactory.getLogger(NoBackend.class);
        log.error("this message goes nowhere");
        System.out.println("logger class: " + log.getClass().getName());
    }
}
$ java -cp lib/slf4j-api-2.0.17.jar NoBackend.java
SLF4J(W): No SLF4J providers were found.
SLF4J(W): Defaulting to no-operation (NOP) logger implementation
SLF4J(W): See https://www.slf4j.org/codes.html#noProviders for further details.
logger class: org.slf4j.helpers.NOPLogger

SLF4J warns once at startup, then discards every call. If production logs are mysteriously empty, check that exactly one backend jar is on the runtime classpath.

Placeholders, not concatenation

With {}, the message — and each argument’s toString() — is only built if the level is enabled.

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

public class Placeholders {
    private static final Logger log = LoggerFactory.getLogger(Placeholders.class);
    static int toStringCalls = 0;

    record Order(String id) {
        @Override public String toString() { toStringCalls++; return "Order(" + id + ")"; }
    }

    public static void main(String[] args) {
        ((ch.qos.logback.classic.Logger) log).setLevel(ch.qos.logback.classic.Level.INFO);
        Order order = new Order("ORD-1");
        for (int i = 0; i < 1000; i++) log.debug("processing " + order);   // concatenation
        System.out.println("concatenation: toString called " + toStringCalls + " times");
        toStringCalls = 0;
        for (int i = 0; i < 1000; i++) log.debug("processing {}", order);   // placeholder
        System.out.println("placeholder:   toString called " + toStringCalls + " times");
    }
}
$ java -cp "lib/*" Placeholders.java
concatenation: toString called 1000 times
placeholder:   toString called 0 times

DEBUG was off, so neither loop printed anything — but the concatenating version still built 1,000 strings for nothing. For arguments that are themselves expensive to compute, wrap the call in if (log.isDebugEnabled()). (Casting to Logback’s Logger to change the level is only for this demo; real code sets levels in configuration.)

Logging exceptions

Pass the exception as the last argument, with no placeholder for it, and Logback prints its stack trace.

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

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

    public static void main(String[] args) {
        String input = "12a";
        try {
            Integer.parseInt(input);
        } catch (NumberFormatException e) {
            log.error("could not parse quantity '{}'", input, e);
            log.error("message only: {}", e.getMessage());
        }
    }
}
$ java -cp "lib/*" Exceptions.java
16:02:14.372 [main] ERROR Exceptions -- could not parse quantity '12a'
java.lang.NumberFormatException: For input string: "12a"
	at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67)
	at java.base/java.lang.Integer.parseInt(Integer.java:662)
	at java.base/java.lang.Integer.parseInt(Integer.java:778)
	at Exceptions.main(Exceptions.java:10)
	at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103)
	at java.base/java.lang.reflect.Method.invoke(Method.java:580)
	at jdk.compiler/com.sun.tools.javac.launcher.Main.execute(Main.java:484)
	at jdk.compiler/com.sun.tools.javac.launcher.Main.run(Main.java:208)
	at jdk.compiler/com.sun.tools.javac.launcher.Main.main(Main.java:135)
16:02:14.376 [main] ERROR Exceptions -- message only: For input string: "12a"

The first call has one {} and two arguments, so SLF4J treats the trailing Throwable as the exception and logs the full trace. The second records only the message, losing where the error happened — avoid it for errors. The launcher.Main frames appear because the program was started with java File.java.

Configuring levels and format with logback.xml

A logback.xml on the classpath replaces the defaults. Loggers form a hierarchy by name, so one setting can quiet a whole package.

<!-- File: logback.xml -->
<configuration>
  <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
    <encoder>
      <pattern>%-5level %logger{20} - %msg%n</pattern>
    </encoder>
  </appender>

  <logger name="com.example.db" level="WARN"/>
  <logger name="com.example.api" level="DEBUG"/>

  <root level="INFO">
    <appender-ref ref="CONSOLE"/>
  </root>
</configuration>
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class Levels {
    public static void main(String[] args) {
        for (String name : new String[] {"com.example.api.Orders", "com.example.db.Pool", "com.example.Main"}) {
            Logger log = LoggerFactory.getLogger(name);
            log.debug("debug from {}", name);
            log.info("info from {}", name);
            log.warn("warn from {}", name);
        }
    }
}
$ java -cp "lib/*:." Levels.java
DEBUG c.example.api.Orders - debug from com.example.api.Orders
INFO  c.example.api.Orders - info from com.example.api.Orders
WARN  c.example.api.Orders - warn from com.example.api.Orders
WARN  com.example.db.Pool - warn from com.example.db.Pool
INFO  com.example.Main - info from com.example.Main
WARN  com.example.Main - warn from com.example.Main

com.example.api.Orders inherits DEBUG from com.example.api; com.example.db.Pool inherits WARN; com.example.Main matches neither, so it uses the root level, INFO. %logger{20} abbreviated the package names to fit 20 characters. The . added to -cp is what puts logback.xml on the classpath.

TokenMeaning
%d{HH:mm:ss.SSS}timestamp
%threadthread name
%-5levellevel, padded to 5 characters
%logger{20}logger name, abbreviated to about 20 characters
%msg / %nthe message / a newline
%X{key}an MDC value (below)

Setting the level from outside

A property with a default lets operators change verbosity without editing the file.

<!-- File: logback.xml -->
<configuration>
  <property name="LOG_LEVEL" value="${LOG_LEVEL:-INFO}"/>
  <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
    <encoder><pattern>%-5level %msg%n</pattern></encoder>
  </appender>
  <root level="${LOG_LEVEL}">
    <appender-ref ref="CONSOLE"/>
  </root>
</configuration>
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

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

    public static void main(String[] args) {
        log.debug("cache warmed with {} entries", 512);
        log.info("ready");
    }
}
$ java -cp "lib/*:." Configurable.java
INFO  ready
$ java -DLOG_LEVEL=DEBUG -cp "lib/*:." Configurable.java
DEBUG cache warmed with 512 entries
INFO  ready
$ LOG_LEVEL=DEBUG java -cp "lib/*:." Configurable.java
DEBUG cache warmed with 512 entries
INFO  ready

Logback resolves ${LOG_LEVEL} from system properties first, then environment variables, then the :-INFO default — so both -D and an environment variable work.

MDC: context on every line

The Mapped Diagnostic Context attaches key-value pairs to the current thread; %X{key} prints them. Set a request id once and every line logged while handling that request carries it.

<!-- File: logback.xml -->
<configuration>
  <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
    <encoder><pattern>[%X{requestId:-none}] %-5level %logger{0} - %msg%n</pattern></encoder>
  </appender>
  <root level="INFO"><appender-ref ref="CONSOLE"/></root>
</configuration>
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;

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

    static void handle(String requestId, String user) {
        MDC.put("requestId", requestId);
        try {
            log.info("start for {}", user);
            new OrderService().place(user);
        } finally {
            MDC.clear();            // threads are reused; never leak context
        }
    }

    public static void main(String[] args) {
        handle("a1b2", "alice");
        handle("c3d4", "bob");
        log.info("between requests");
    }
}

class OrderService {
    private static final Logger log = LoggerFactory.getLogger(OrderService.class);
    void place(String user) { log.info("order placed for {}", user); }
}
$ java -cp "lib/*:." Requests.java
[a1b2] INFO  Requests - start for alice
[a1b2] INFO  OrderService - order placed for alice
[c3d4] INFO  Requests - start for bob
[c3d4] INFO  OrderService - order placed for bob
[none] INFO  Requests - between requests

OrderService never received the request id as a parameter, yet its lines carry it. The finally matters: in a thread pool the next request may run on the same thread, and a stale id would mislabel its logs. %X{requestId:-none} supplies a default when the key is absent.

Files and JSON

Appenders decide where lines go. Here one appender writes JSON to the console for a log aggregator, and another writes plain text to a file.

<!-- File: logback.xml -->
<configuration>
  <appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
    <encoder class="net.logstash.logback.encoder.LogstashEncoder"/>
  </appender>
  <appender name="FILE" class="ch.qos.logback.core.FileAppender">
    <file>logs/app.log</file>
    <encoder><pattern>%-5level %logger{0} - %msg%n</pattern></encoder>
  </appender>
  <root level="INFO">
    <appender-ref ref="JSON"/>
    <appender-ref ref="FILE"/>
  </root>
</configuration>
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;

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

    public static void main(String[] args) {
        MDC.put("requestId", "a1b2");
        log.info("user created id={}", 42);
        MDC.clear();
    }
}
$ java -cp "lib/*:." JsonLogs.java
{"@timestamp":"2026-10-07T16:02:19.189855958Z","@version":"1","message":"user created id=42","logger_name":"JsonLogs","thread_name":"main","level":"INFO","level_value":20000,"requestId":"a1b2"}
$ cat logs/app.log
INFO  JsonLogs - user created id=42

The JSON line is one object per event with the MDC entry as its own field, which tools like Elasticsearch, Datadog or Loki index without parsing text. The timestamp is the real run time. In production you would use a RollingFileAppender with a size or time policy so files don’t grow forever.

Best practices

  • One private static final Logger per class, named after the class.
  • Always {} placeholders; pass exceptions as the last argument.
  • INFO for business events, DEBUG for diagnostics, WARN for handled surprises, ERROR for failures.
  • Never log passwords, tokens or full card numbers — logs are copied to many systems.
  • Put request or job ids in the MDC instead of threading them through method parameters.

Practice

  1. Log a password-reset event without leaking the email: mask everything before the @ except the first character.
Solution
<!-- File: logback.xml -->
<configuration>
  <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
    <encoder><pattern>%-5level %msg%n</pattern></encoder>
  </appender>
  <root level="INFO"><appender-ref ref="CONSOLE"/></root>
</configuration>
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

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

    static String mask(String email) {
        int at = email.indexOf('@');
        return at <= 1 ? "***" + email.substring(Math.max(at, 0)) : email.charAt(0) + "***" + email.substring(at);
    }

    public static void main(String[] args) {
        log.info("password reset requested for {}", mask("alice.smith@example.com"));
    }
}
$ java -cp "lib/*:." Masking.java
INFO  password reset requested for a***@example.com

The mask runs before logging, so the full address never reaches any appender or log file.

  1. Silence everything below ERROR for a logger named com.vendor.noisy while your own app logger stays at INFO.
Solution
<!-- File: logback.xml -->
<configuration>
  <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
    <encoder><pattern>%-5level %logger - %msg%n</pattern></encoder>
  </appender>
  <logger name="com.vendor.noisy" level="ERROR"/>
  <root level="INFO"><appender-ref ref="CONSOLE"/></root>
</configuration>
import org.slf4j.LoggerFactory;

public class Quiet {
    public static void main(String[] args) {
        var vendor = LoggerFactory.getLogger("com.vendor.noisy.Client");
        var app = LoggerFactory.getLogger("app");
        vendor.info("connection pool stats ...");
        vendor.warn("slow response");
        vendor.error("connection refused");
        app.info("order saved");
    }
}
$ java -cp "lib/*:." Quiet.java
ERROR com.vendor.noisy.Client - connection refused
INFO  app - order saved

com.vendor.noisy.Client inherits ERROR from its parent logger name; everything else keeps the root’s INFO.

  1. Log the time an operation took, at DEBUG, with isDebugEnabled() guarding the measurement, and run it with DEBUG off and on via a LOG_LEVEL property.
Solution
<!-- File: logback.xml -->
<configuration>
  <property name="LOG_LEVEL" value="${LOG_LEVEL:-INFO}"/>
  <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
    <encoder><pattern>%-5level %msg%n</pattern></encoder>
  </appender>
  <root level="${LOG_LEVEL}"><appender-ref ref="CONSOLE"/></root>
</configuration>
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

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

    public static void main(String[] args) throws InterruptedException {
        long start = log.isDebugEnabled() ? System.nanoTime() : 0;
        Thread.sleep(50);                                   // the "work"
        if (log.isDebugEnabled()) log.debug("work took ~{} ms (varies)", (System.nanoTime() - start) / 1_000_000);
        log.info("done");
    }
}
$ java -cp "lib/*:." Timing.java
INFO  done
$ java -DLOG_LEVEL=DEBUG -cp "lib/*:." Timing.java
DEBUG work took ~50 ms (varies)
INFO  done

With DEBUG off, neither the clock read nor the message happens. The measured time is real and will vary slightly per run.

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.
What does 'No SLF4J providers were found' mean?
Your code compiled against slf4j-api, but no logging backend such as logback-classic or slf4j-simple is on the runtime classpath. SLF4J then falls back to a no-operation logger and every log call is silently discarded. Add exactly one backend dependency to fix it.