Out of the box Spring Boot logs through the SLF4J facade backed by the Logback implementation.
spring-boot-starter-logging (pulled in transitively by every other starter) wires this up, so you get
working console logging with zero configuration.
// SLF4J is the API you code against — Logback is what runs underneath
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class OrderService {
private static final Logger log = LoggerFactory.getLogger(OrderService.class);
// log.info(...) -> SLF4J -> Logback -> console
}
A facade like SLF4J lets you write code against one logging API while keeping the freedom to swap the underlying engine (Logback, Log4j2, java.util.logging) without touching application code.
Rule of thumb: Code against the SLF4J Logger, never against Logback directly — that's what
keeps your logging implementation replaceable.
A facade decouples your code from the logging implementation. Your classes only ever import
org.slf4j.Logger; whether the bytes end up in Logback, Log4j2, or JUL is a dependency-time decision,
not a code change. It also unifies the dozens of logging APIs your transitive dependencies use.
<!-- Swapping engines is a build change, not a code change -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-logging</artifactId> <!-- SLF4J + Logback -->
</dependency>
Spring Boot bridges legacy APIs (Jakarta Commons Logging, Log4j 1.x, JUL) onto SLF4J so libraries that use them still funnel through the same configuration and output as your own logs.
Rule of thumb: A facade buys you one config to rule them all and the freedom to change engines — that's why every serious Java app logs through SLF4J.
Spring Framework's internal code is written against Jakarta Commons Logging (JCL), but Spring Boot
doesn't ship the real Commons Logging jar. Instead spring-jcl provides a compile-time replacement
that routes JCL calls to SLF4J, so framework logs and your logs share one pipeline.
<!-- spring-jcl: a JCL API surface that delegates to SLF4J at runtime -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-jcl</artifactId>
</dependency>
This is why you can set logging.level.org.springframework=DEBUG and actually see Spring's internals —
they end up in the same Logback configuration as everything else. The same bridging trick handles
Log4j 1.x and java.util.logging calls from other libraries.
Rule of thumb: Don't add a separate commons-logging dependency — Boot's spring-jcl already
bridges it to SLF4J, and adding the real one causes duplicate-binding conflicts.
Set them as properties — no XML required. Use logging.level.<package-or-class> for a specific area and
logging.level.root for the global default. Levels are case-insensitive.
logging:
level:
root: INFO # global default
org.springframework.web: DEBUG # whole package
com.example.OrderService: TRACE # a single class
org.hibernate.SQL: DEBUG # see executed SQL
Levels are hierarchical: a setting on a package applies to everything beneath it unless overridden by
a more specific entry. You can also pass them on the command line
(--logging.level.com.example=DEBUG) or via environment variables for quick, no-rebuild tweaks.
Rule of thumb: Keep root at INFO in production and raise levels narrowly on the package
you're debugging — never flip the whole app to DEBUG.
A logging group maps one name to several packages so you can set their level in a single shot.
Spring Boot ships two built-in groups — web and sql — and you can define your own under
logging.group.*.
logging:
group:
payments: com.example.billing,com.example.gateway # custom group
level:
web: DEBUG # built-in: Spring web, codecs, HTTP client, etc.
sql: DEBUG # built-in: JDBC, Hibernate SQL, JdbcTemplate
payments: TRACE # flips both packages above at once
Without groups you'd repeat several logging.level.* lines and risk forgetting one. The built-in web
and sql groups are especially handy for turning on request or SQL tracing during an incident.
Rule of thumb: Bundle the packages you debug together into a named group so you can raise their level with one toggle instead of hunting down every package.
By default Boot logs only to the console. Enable a file by setting logging.file.name (an exact
path/filename) or logging.file.path (a directory, where Boot writes spring.log). Set one, not
both.
logging:
file:
name: /var/log/myapp/application.log # exact file
# path: /var/log/myapp # OR a directory -> /var/log/myapp/spring.log
Once a file is configured, Boot logs to both the console and the file. The file output uses a plain (uncolored) pattern by default, and rolling is enabled automatically.
Rule of thumb: In containers, prefer console-only logging (let the platform collect stdout); use
logging.file.name when you run on a VM and need a local file to tail.
Override logging.pattern.console and logging.pattern.file with Logback pattern syntax. Spring
Boot exposes convenient placeholders like %clr for ANSI color and a LOG_LEVEL_PATTERN for MDC.
# Timestamp, colored level, thread, logger (40 chars), message + newline
logging.pattern.console=%d{HH:mm:ss.SSS} %clr(%-5level) [%thread] %logger{40} - %msg%n
logging.pattern.file=%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%thread] %logger - %msg%n
Console output is colored by default when the terminal supports ANSI; control it with
spring.output.ansi.enabled (detect/always/never). The file pattern is intentionally plain so
log files stay free of escape codes.
Rule of thumb: Tweak patterns with logging.pattern.* for simple needs; if you need real structure,
jump to structured JSON rather than hand-crafting an elaborate text pattern.
When file logging is on, Logback's rolling policy is active. Boot surfaces it through
logging.logback.rollingpolicy.* so you can tune it without touching XML — files roll by size and/or
day and old archives are pruned.
logging:
file:
name: /var/log/myapp/app.log
logback:
rollingpolicy:
max-file-size: 50MB # roll when a file hits this size
max-history: 14 # keep 14 days of archives
total-size-cap: 2GB # hard ceiling on all archives combined
file-name-pattern: ${LOG_FILE}.%d{yyyy-MM-dd}.%i.gz # gzip rolled files
Without limits a log file grows until it fills the disk — a classic 3am outage. max-history and
total-size-cap bound retention; max-file-size keeps individual files tail-able.
Rule of thumb: Always set max-history and total-size-cap in production so logs can never eat
the disk.
Exclude the default logging starter and add spring-boot-starter-log4j2. Boot detects Log4j2 on
the classpath and configures it automatically. The usual reason is performance — Log4j2's
asynchronous loggers (via the LMAX Disruptor) offer very high throughput and low latency.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<exclusions><exclusion> <!-- drop Logback -->
<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> <!-- add Log4j2 -->
</dependency>
You then configure it with log4j2-spring.xml instead of logback-spring.xml. For most apps Logback is
perfectly fine; switch only when you've measured a logging bottleneck or your org standardizes on
Log4j2.
Rule of thumb: Stay on the default Logback unless async throughput is a proven need — switching engines is a maintenance cost you should pay deliberately.
Logback reads logback.xml very early, before Spring's environment exists. Spring Boot instead
recommends logback-spring.xml, which Boot loads itself — and that timing means you get
Spring-only extensions and access to Spring properties and profiles.
<!-- logback-spring.xml: Boot loads this, so Spring extensions work -->
<configuration>
<!-- include Boot's sensible console defaults -->
<include resource="org/springframework/boot/logging/logback/base.xml"/>
<springProperty name="appName" source="spring.application.name"/>
<springProfile name="prod">
<root level="WARN"/>
</springProfile>
</configuration>
With plain logback.xml, the <springProfile> and <springProperty> tags don't work because Spring
hasn't initialized when Logback parses the file.
Rule of thumb: Name your config logback-spring.xml so profile- and property-aware logging is
available — reserve plain logback.xml for cases where you deliberately want Logback to ignore Spring.
They are Spring Boot's Logback extensions. <springProfile> makes a config block conditional on
active profiles; <springProperty> pulls a value from Spring's Environment (properties,
application.yml, env vars) into a Logback variable.
<configuration>
<!-- read a Spring property into a Logback variable -->
<springProperty scope="context" name="logPath" source="app.log.path"/>
<springProfile name="dev"> <!-- only when 'dev' is active -->
<root level="DEBUG"><appender-ref ref="CONSOLE"/></root>
</springProfile>
<springProfile name="prod"> <!-- only when 'prod' is active -->
<root level="INFO"><appender-ref ref="FILE"/></root>
</springProfile>
</configuration>
This lets a single logging config behave differently per environment — verbose console in dev, lean file or JSON output in prod — instead of maintaining separate files.
Rule of thumb: Use <springProfile> to vary appenders/levels per environment and <springProperty>
to inject config values — both require the -spring filename.
Since Spring Boot 3.4, structured logging is built in — no extra library. Set the format property
to a known schema (ecs for Elastic Common Schema, logstash, or gelf) and Boot emits one
JSON object per line, ready for log aggregators.
logging:
structured:
format:
console: ecs # JSON on stdout (Elastic Common Schema)
file: logstash # different schema for the file appender
file:
name: /var/log/myapp/app.json
Machine-parseable JSON lets Elasticsearch/Loki/Splunk index every field — level, logger, MDC keys,
stack traces — so you can query and aggregate instead of grepping free text. You can also register a
custom format. Before 3.4 you'd add logstash-logback-encoder and configure an encoder manually.
Rule of thumb: On Boot 3.4+, switch the console to a structured format (ecs/logstash) whenever
logs flow into a central aggregator — humans read the UI, machines read the JSON.
Use the actuator /loggers endpoint. A GET shows the configured and effective levels; a POST
with a configuredLevel body changes a logger live — invaluable for debugging a production incident
without redeploying.
# Inspect the current level for a package
curl localhost:8080/actuator/loggers/com.example.OrderService
# Raise it to DEBUG at runtime (POST), then set it back to null to reset
curl -X POST localhost:8080/actuator/loggers/com.example.OrderService \
-H 'Content-Type: application/json' \
-d '{"configuredLevel":"DEBUG"}'
You must expose the endpoint (management.endpoints.web.exposure.include=loggers) and secure it —
it changes runtime behavior. Setting configuredLevel to null reverts a logger to its inherited level.
Rule of thumb: Reach for /loggers to flip a single package to DEBUG during an incident, then
reset it — far safer than a redeploy, but keep the endpoint locked down.
SLF4J's {} placeholders defer message construction until the framework knows the level is enabled,
avoiding wasted string building. String concatenation builds the message every time, even when the
log is suppressed.
// GOOD: arguments only formatted if DEBUG is actually enabled
log.debug("Processing order {} for customer {}", orderId, customerId);
// BAD: the concatenation (and any toString) runs even when DEBUG is off
log.debug("Processing order " + orderId + " for customer " + bigObject);
// For an expensive argument, guard it explicitly:
if (log.isDebugEnabled()) log.debug("dump = {}", expensiveDump());
Beyond performance, placeholders keep messages consistent and tidy, and you should never log secrets or PII — passwords, tokens, full card or SSN numbers. Logs are widely readable and long-lived.
Rule of thumb: Always use {} placeholders, guard truly expensive arguments with
isDebugEnabled(), and treat logs as public — keep secrets and PII out.
MDC (Mapped Diagnostic Context) is a per-thread key/value map SLF4J keeps; values you put in it can be
printed in every log line via the pattern %X{key}. The classic use is stamping a
correlation/trace ID on all logs for one request so you can follow a single transaction.
// In a filter or interceptor, at the start of each request:
MDC.put("traceId", request.getHeader("X-Trace-Id")); // or generate a UUID
try {
chain.doFilter(request, response); // all logs now carry traceId
} finally {
MDC.clear(); // CRITICAL: clear so pooled threads don't leak context
}
# Reference the MDC key in your pattern
logging.pattern.level=%5p [%X{traceId:-}]
With Micrometer Tracing, Boot can populate traceId/spanId in the MDC for you. The non-negotiable rule
is to clear the MDC after the request, since servlet threads are pooled and reused.
Rule of thumb: Put a correlation ID in the MDC per request and print it with %X{...} — but
always clear it in a finally to avoid bleeding context across pooled threads.
The --debug command-line flag (or debug=true in properties) is not the same as root=DEBUG. It
enables DEBUG for a curated set of core loggers (embedded server, Spring Boot, Hibernate) and prints
the auto-configuration report. --trace/trace=true does the same at TRACE. Separately, Lombok's
@Slf4j removes the boilerplate LoggerFactory line.
import lombok.extern.slf4j.Slf4j;
@Slf4j // generates: private static final Logger log = ...
@Service
public class OrderService {
public void place(String id) {
log.info("Placing order {}", id); // 'log' field provided by Lombok
}
}
--debug is great for understanding why a bean was or wasn't auto-configured, without drowning every
package in DEBUG output. @Slf4j just generates the conventional log field at compile time.
Rule of thumb: Use --debug to read the auto-config report when diagnosing startup, not as a
blanket DEBUG switch — and let @Slf4j write your logger boilerplate.
More Actuator & Observability interview questions
More ways to practice
The self-quiz is live. Join our channel for updates, new content & tech tips.