Java · lesson 45 of 66
Beginner
17- 1 Introduction to Java
- 2 Setting Up Java
- 3 Data Types in Java
- 4 Operators in Java
- 5 Variables and Operators in Java
- 6 Control Flow in Java
- 7 Arrays in Java
- 8 Functions (Methods) in Java
- 9 Strings in Java
- 10 Methods in Java
- 11 Java OOP — Complete Guide
- 12 Introduction to Object-Oriented Programming in Java
- 13 Encapsulation in Java
- 14 Inheritance in Java
- 15 Polymorphism in Java
- 17 Abstraction in Java
- 18 Error Types in Java
Intermediate
38- 16 Regular Expressions in Java
- 19 Interfaces in Java — A Deep Dive
- 20 Abstract Classes in Java
- 21 Method Overloading in Java
- 22 Composition vs Inheritance in Java
- 23 Generics in Java
- 27 Exception Handling in Java
- 28 Java Streams API
- 29 Collections Framework in Java
- 30 Optional in Java
- 31 File Handling in Java
- 32 Records in Java
- 33 Java 8 Features
- 34 Sealed Classes and Interfaces in Java
- 35 Multithreading in Java
- 37 JDBC — Database Access in Java
- 39 Maven — Java Build and Dependency Management
- 42 Gradle — Modern Java Build Tool
- 44 Unit Testing with JUnit 5 and Mockito
- 45 Logging in Java with SLF4J and Logback
- 49 Spring Boot — Introduction
- 50 Dependency Injection in Spring Boot
- 51 Java Project Ideas
- 52 Building REST APIs with Spring Boot
- 53 Validation in Spring Boot
- 54 Spring Data JPA — Database Access
- 55 Exception Handling in Spring Boot
- 56 Spring Security — Authentication and Authorization
- 57 JWT Authentication in Spring Boot
- 58 Dockerizing Spring Boot Applications
- 59 Java Enums: Beyond Simple Constants
- 60 Inner and Anonymous Classes in Java
- 61 Java Generics: Bounded Types, Wildcards, and Generic Methods
- 62 Java Date/Time API: LocalDate, LocalDateTime, and Duration
- 63 Java NIO.2: Paths, Files, and WatchService
- 64 Java Annotations: Custom Types and Meta-Information
- 65 Java Reflection: Dynamic Type Inspection and Method Invocation
- 66 Java Localization: ResourceBundle and Locale Support
Advanced
11Logging 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.
| Token | Meaning |
|---|---|
%d{HH:mm:ss.SSS} | timestamp |
%thread | thread name |
%-5level | level, padded to 5 characters |
%logger{20} | logger name, abbreviated to about 20 characters |
%msg / %n | the 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 Loggerper 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
- 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.
- Silence everything below ERROR for a logger named
com.vendor.noisywhile your ownapplogger 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.
- Log the time an operation took, at DEBUG, with
isDebugEnabled()guarding the measurement, and run it with DEBUG off and on via aLOG_LEVELproperty.
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.