Logging in Spring Boot: SLF4J, Logback and Log Levels

Introduction

This tutorial explains logging in Spring Boot: how an application writes log messages, how Spring Boot formats them, and how to control what gets logged and where. We will start with the pieces involved - SLF4J, the API you call, and Logback, the library that does the work - and write log messages at each level from a small application. We will then read Spring Boot's default log format, change log levels per package, and group packages so that one setting controls several. Next we will change the format of the lines, switch to structured JSON logging with context data, and write the log to a file. Lastly, we will look at logback-spring.xml for full control, swapping Logback for Log4j 2, and best practices.

(This tutorial sets logging properties on the command line; any of them can equally go in application.properties or application.ymlClick to read tutorial: application.properties vs application.yml in Spring Boot, or in a profile-specific fileClick to read tutorial: Spring Boot Profiles and Environment-Specific Configuration.)

SLF4J and Logback

Java has several logging libraries, and an application usually contains libraries that use different ones. Spring Boot brings order with two layers -
  • SLF4J (Simple Logging Facade for Java) is the API: your code calls org.slf4j.Logger methods such as info() and warn(), and does not depend on any particular logging library.
  • Logback is the implementation behind SLF4J that Spring Boot uses by default: it decides which messages to keep, formats them, and writes them to the console or a file.
  • Bridges route the other logging APIs into SLF4J - log4j-to-slf4j for Log4j 2 calls and jul-to-slf4j for java.util.logging - so every library's messages end up in the same place, in the same format.
All of this comes with spring-boot-starter-logging, which every starter includes (it showed up in the dependency tree in Spring Boot Starters ExplainedClick to read tutorial: Spring Boot Starters Explained with Examples). Spring Boot then configures Logback itself at startup, so an application needs no logging configuration file at all.

Writing log messages

The application below has two services in two packages, an order service and a payment service. Each gets a logger with LoggerFactory.getLogger() and logs at each of SLF4J's five levels, from the most detailed to the most severe: TRACE, DEBUG, INFO, WARN and ERROR -
com.javabrahman.boot.logging.orders - OrderService logging at every level
//OrderService.java
package com.javabrahman.boot.logging.orders;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
import org.springframework.stereotype.Service;

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

  public void placeOrder(int orderId, String customer, double amount) {
    MDC.put("orderId", String.valueOf(orderId));   // context data, used by structured logging below
    try {
      log.trace("Validating order {} line by line", orderId);
      log.debug("Order {} for {} has amount {}", orderId, customer, amount);
      log.info("Order {} placed by {}", orderId, customer);
      if (amount > 1000) {
        log.warn("Order {} is unusually large: {}", orderId, amount);
      }
    } finally {
      MDC.remove("orderId");
    }
  }
}
com.javabrahman.boot.logging.payments - PaymentService logging an exception
//PaymentService.java
package com.javabrahman.boot.logging.payments;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;

@Service
public class PaymentService {
  private static final Logger log = LoggerFactory.getLogger(PaymentService.class);

  public void charge(int orderId, String cardNumber) {
    log.debug("Charging order {}", orderId);
    try {
      if (cardNumber.isBlank()) {
        throw new IllegalArgumentException("card number is empty");
      }
    } catch (IllegalArgumentException e) {
      log.error("Payment for order {} failed: {}", orderId, e.getMessage());
    }
  }
}
LoggingApp.java - calls both services
//LoggingApp.java
package com.javabrahman.boot.logging;

import com.javabrahman.boot.logging.orders.OrderService;
import com.javabrahman.boot.logging.payments.PaymentService;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class LoggingApp {
  public static void main(String[] args) {
    try (var context = SpringApplication.run(LoggingApp.class, args)) {
      context.getBean(OrderService.class).placeOrder(1001, "Tom Jones", 1250.0);
      context.getBean(PaymentService.class).charge(1001, " ");
    }
  }
}
Running it with only the application name set -  OUTPUT with the default configuration
2026-11-06T13:40:39.357+05:30  INFO 4804 --- [shop] [           main] c.j.boot.logging.orders.OrderService     : Order 1001 placed by Tom Jones
2026-11-06T13:40:39.365+05:30  WARN 4804 --- [shop] [           main] c.j.boot.logging.orders.OrderService     : Order 1001 is unusually large: 1250.0
2026-11-06T13:40:39.368+05:30 ERROR 4804 --- [shop] [           main] c.j.b.logging.payments.PaymentService    : Payment for order 1001 failed: card number is empty

Reading the default format

Only three of the seven messages appeared: Spring Boot's default level is INFO, so TRACE and DEBUG messages are discarded. Each line has these parts -
  • Date and time with milliseconds and the time-zone offset, in ISO-8601 form.
  • Level - INFO, WARN, ERROR.
  • Process id of the JVM, then a --- separator.
  • Application name in brackets, from spring.application.name; it is left out when the name is not set.
  • Thread name in brackets, padded to a fixed width.
  • Logger name, normally the class name, shortened to fit (c.j.b.logging.orders.OrderService).
  • The message, after a colon. The {} placeholders were filled in with the arguments.
In a terminal that supports it, Spring Boot also colours the level and some other parts.

Use placeholders, not string concatenation. With log.debug("Order {} ...", orderId), the message string is built only if DEBUG is enabled. With log.debug("Order " + orderId + ...), it is built every time, even when the line is thrown away.

Changing log levels

The logging.level.<logger> property sets the level for a logger and everything below it in the package hierarchy; logging.level.root sets the default for everything. Turning on DEBUG for the order package only -  OUTPUT with logging.level.com.javabrahman.boot.logging.orders=DEBUG
2026-11-06T13:40:45.998+05:30 DEBUG 28864 --- [shop] [           main] c.j.boot.logging.orders.OrderService     : Order 1001 for Tom Jones has amount 1250.0
2026-11-06T13:40:46.007+05:30  INFO 28864 --- [shop] [           main] c.j.boot.logging.orders.OrderService     : Order 1001 placed by Tom Jones
2026-11-06T13:40:46.007+05:30  WARN 28864 --- [shop] [           main] c.j.boot.logging.orders.OrderService     : Order 1001 is unusually large: 1250.0
2026-11-06T13:40:46.009+05:30 ERROR 28864 --- [shop] [           main] c.j.b.logging.payments.PaymentService    : Payment for order 1001 failed: card number is empty
And raising the root level to WARN, to keep only warnings and errors -  OUTPUT with logging.level.root=WARN
2026-11-06T13:40:53.108+05:30  WARN 18120 --- [shop] [           main] c.j.boot.logging.orders.OrderService     : Order 1001 is unusually large: 1250.0
2026-11-06T13:40:53.112+05:30 ERROR 18120 --- [shop] [           main] c.j.b.logging.payments.PaymentService    : Payment for order 1001 failed: card number is empty

Explanation of the output

  • With DEBUG for the orders package, the order service's debug line appeared - but not its TRACE line, which needs TRACE, and not the payment service's debug line, which is in another package.
  • With the root at WARN, the INFO line disappeared and only the warning and the error remained.
  • Levels can be set for any library the same way. Common ones are logging.level.org.springframework.web=DEBUG to see how Spring MVC handles each request, and logging.level.org.hibernate.SQL=DEBUG to see the SQL that JPA runs.
  • Setting logging.level.root=DEBUG is rarely useful: every library logs at debug, and the interesting lines drown.

Log groups

Debugging a feature often means changing the level of several packages at once. A log group gives them one name. Grouping the order and payment packages as checkout, and setting the group to DEBUG -  OUTPUT with a checkout log group at DEBUG
2026-11-06T13:40:59.708+05:30 DEBUG 16812 --- [shop] [           main] c.j.boot.logging.orders.OrderService     : Order 1001 for Tom Jones has amount 1250.0
2026-11-06T13:40:59.715+05:30  INFO 16812 --- [shop] [           main] c.j.boot.logging.orders.OrderService     : Order 1001 placed by Tom Jones
2026-11-06T13:40:59.716+05:30  WARN 16812 --- [shop] [           main] c.j.boot.logging.orders.OrderService     : Order 1001 is unusually large: 1250.0
2026-11-06T13:40:59.718+05:30 DEBUG 16812 --- [shop] [           main] c.j.b.logging.payments.PaymentService    : Charging order 1001
2026-11-06T13:40:59.718+05:30 ERROR 16812 --- [shop] [           main] c.j.b.logging.payments.PaymentService    : Payment for order 1001 failed: card number is empty
Now both services logged their debug lines. Spring Boot predefines two groups: web (Spring MVC, codecs and related packages) and sql (Spring JDBC, jOOQ and Hibernate SQL logging), so logging.level.web=DEBUG is the quick way to watch requests.

Changing the format

The logging.pattern.console property replaces the console format with your own Logback pattern. A shorter format for local development, with the time, the level, the logger's simple class name and the message, in a configuration file loaded with --spring.config.name=pattern -
pattern.properties - a custom console pattern
logging.pattern.console=%d{HH:mm:ss.SSS} %-5level %logger{0} - %msg%n
 OUTPUT with --spring.config.name=pattern
13:41:06.779 INFO  OrderService - Order 1001 placed by Tom Jones
13:41:06.779 WARN  OrderService - Order 1001 is unusually large: 1250.0
13:41:06.787 ERROR PaymentService - Payment for order 1001 failed: card number is empty
In the pattern, %d{...} is the date in the given format, %-5level the level padded to five characters, %logger{0} the logger name shortened to its last part, %msg the message and %n a line break. logging.pattern.file does the same for the log file, and logging.pattern.dateformat changes only the date part of the default format.

Structured logging in JSON

Logs read by people are best as text; logs read by machines - collected into Elasticsearch, Grafana Loki or a cloud logging service - are best as JSON, one object per line, with every part in its own field. Spring Boot has structured logging built in, with three standard formats: ecs (Elastic Common Schema), logstash and gelf (Graylog) -  OUTPUT with logging.structured.format.console=ecs
{"@timestamp":"2026-11-06T08:11:13.636086800Z","log":{"level":"INFO","logger":"com.javabrahman.boot.logging.orders.OrderService"},"process":{"pid":19120,"thread":{"name":"main"}},"service":{"name":"shop","node":{}},"message":"Order 1001 placed by Tom Jones","orderId":"1001","ecs":{"version":"8.11"}}
{"@timestamp":"2026-11-06T08:11:13.676505900Z","log":{"level":"WARN","logger":"com.javabrahman.boot.logging.orders.OrderService"},"process":{"pid":19120,"thread":{"name":"main"}},"service":{"name":"shop","node":{}},"message":"Order 1001 is unusually large: 1250.0","orderId":"1001","ecs":{"version":"8.11"}}
{"@timestamp":"2026-11-06T08:11:13.683209300Z","log":{"level":"ERROR","logger":"com.javabrahman.boot.logging.payments.PaymentService"},"process":{"pid":19120,"thread":{"name":"main"}},"service":{"name":"shop","node":{}},"message":"Payment for order 1001 failed: card number is empty","ecs":{"version":"8.11"}}

Explanation of the output

  • Each log event became one JSON object with named fields: the timestamp, log.level, the process id and thread, the logger, service.name from spring.application.name, and the message. A log collector can index and search on every field.
  • The order service's lines carry an orderId field. It came from the MDC (Mapped Diagnostic Context): MDC.put("orderId", ...) attaches a value to every log line written by that thread until it is removed. Putting a request id or order id in the MDC lets you find every line that belongs to one request.
  • The payment service's error line has no orderId, because placeOrder() removed it from the MDC in its finally block - always clean up the MDC, since threads are reused.
  • logging.structured.format.file switches the log file to JSON independently, so the console can stay human-readable.

Writing the log to a file

By default Spring Boot logs only to the console, which suits containers, where the platform collects the console output. To also write a file, set logging.file.name (a file) or logging.file.path (a directory, in which Boot writes spring.log) -  OUTPUT - the contents of logs/shop.log
2026-11-06T13:41:21.065+05:30  INFO 1124 --- [shop] [main] c.j.boot.logging.orders.OrderService     : Order 1001 placed by Tom Jones
2026-11-06T13:41:21.071+05:30  WARN 1124 --- [shop] [main] c.j.boot.logging.orders.OrderService     : Order 1001 is unusually large: 1250.0
2026-11-06T13:41:21.077+05:30 ERROR 1124 --- [shop] [main] c.j.b.logging.payments.PaymentService    : Payment for order 1001 failed: card number is empty

Explanation of the output

  • The console output of this run was discarded, and the box shows the file Spring Boot wrote: the same lines, in the same default format.
  • The file rolls over when it reaches 10 MB by default, and old files are kept for 7 days; logging.logback.rollingpolicy.max-file-size and logging.logback.rollingpolicy.max-history change these limits.
  • Two levels were combined in this run: root=OFF silenced everything, then com.javabrahman=INFO switched our own packages back on - a more specific logger always wins over a more general one.

Full control with logback-spring.xml

Properties cover most needs. For anything more - several files, different levels per appender, sending some loggers to a separate file - put a Logback configuration file in src/main/resources. Name it logback-spring.xml rather than logback.xml: with the -spring name, Spring Boot loads it after the environment is ready, so it can use Spring extensions such as <springProfile> and <springProperty> -
logback-spring.xml - Spring Boot's defaults plus a profile-specific level
<configuration>
  <include resource="org/springframework/boot/logging/logback/defaults.xml"/>
  <include resource="org/springframework/boot/logging/logback/console-appender.xml"/>

  <root level="INFO">
    <appender-ref ref="CONSOLE"/>
  </root>

  <springProfile name="dev">
    <logger name="com.javabrahman" level="DEBUG"/>
  </springProfile>
</configuration>
The two include lines reuse Spring Boot's own defaults and console appender, so the format stays the same and only the additions are yours.

Using Log4j 2 instead of Logback

Spring Boot also supports Log4j 2. Exclude the logging starter from spring-boot-starter (or from the web starter, which includes it) and add the Log4j 2 starter -
pom.xml - replacing Logback with Log4j 2
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-webmvc</artifactId>
  <exclusions>
    <exclusion>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-logging</artifactId>
    </exclusion>
  </exclusions>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-log4j2</artifactId>
</dependency>
Because your code uses only the SLF4J API, nothing else changes: the logging.* properties work the same way, and a log4j2-spring.xml file takes the place of logback-spring.xml.

Logging best practices

  • Use INFO for events that matter to someone running the application (order placed, job finished), WARN for unexpected but handled situations, ERROR for failures that need attention, and DEBUG for details needed while investigating.
  • Always use {} placeholders, and pass an exception as the last argument (log.error("Payment failed", e)) when you want its stack trace logged.
  • Never log passwords, tokens, full card numbers or personal data that the log's readers should not see.
  • Put identifiers such as a request id in the MDC, and use structured logging wherever logs are collected by a tool.
  • Configure levels in properties, per environment through profiles, rather than in code.

Summary

In this tutorial we first saw how SLF4J, Logback and the logging bridges fit together, and logged messages at all five levels from two services. We read Spring Boot's default format and saw that only INFO and above are kept by default. We then changed levels per package and for the root, grouped two packages into a log group, and replaced the console pattern. Next we switched to structured JSON logging in the ECS format, with an order id carried through the MDC, and wrote the log to a rolling file. Lastly, we looked at logback-spring.xml, replacing Logback with Log4j 2, and logging best practices. The next tutorial makes the edit-and-run cycle faster: Spring Boot DevToolsClick to read tutorial: Spring Boot DevTools: Automatic Restart and Productivity.