Skip to content

Spring Boot · Actuator & Observability

Spring Boot Logging: Logback, Levels, Files, and Structured JSON

8 min read Updated 2026-06-26 Share:

Practice Logging interview questions

The stack you already have

The moment you add any Spring Boot starter, you get working logs for free. That's because every starter pulls in spring-boot-starter-logging transitively, and that starter wires up the SLF4J facade in front of the Logback engine. You write code against SLF4J's Logger, and Logback turns those calls into the neatly formatted, colored lines you see on the console:

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

public class OrderService {
    // Code against SLF4J — Logback runs underneath
    private static final Logger log = LoggerFactory.getLogger(OrderService.class);

    public void place(String id) {
        log.info("Placing order {}", id);   // {} placeholder, not concatenation
    }
}

Why a facade? Because it decouples your code from the logging library. Your classes only ever import org.slf4j.Logger, so whether the output ends up in Logback, Log4j2, or java.util.logging is a build-time decision rather than a code change. Spring Boot even bridges the older APIs — Spring Framework's own internals use Commons Logging, and spring-jcl quietly routes those calls onto SLF4J so the framework's logs and your logs share a single configuration and a single output stream. One pipeline, one config: that's the whole point of logging through a facade.

Levels and groups

Most day-to-day logging configuration is just setting levels, and you do that with plain properties — no XML required. Use logging.level.<package> for a specific area and logging.level.root for the global default. Levels are hierarchical, so a setting on a package cascades to everything beneath it:

logging:
  level:
    root: INFO                          # global default
    org.springframework.web: DEBUG      # one package
    com.example.OrderService: TRACE     # a single class
    org.hibernate.SQL: DEBUG            # watch the SQL fly by

When you find yourself flipping several related packages together, reach for a logging group, which maps one name onto many packages. Spring Boot ships two built-ins — web and sql — and you can define your own:

logging:
  group:
    payments: com.example.billing,com.example.gateway   # your own group
  level:
    web: DEBUG       # built-in: Spring web, codecs, HTTP clients
    sql: DEBUG       # built-in: JDBC + Hibernate SQL
    payments: TRACE  # flips both custom packages at once

During an incident the web and sql groups are gold — one toggle and you're tracing requests or SQL without remembering every package name. The discipline that matters: keep root at INFO in production and raise levels narrowly. Flipping the whole app to DEBUG floods you with noise and can leak sensitive data.

Writing to a file, and not filling the disk

By default Boot logs only to the console, which is exactly right for containers — let the platform collect stdout. On a VM where you want a local file to tail, set one of logging.file.name (an exact path) or logging.file.path (a directory, where Boot writes spring.log):

logging:
  file:
    name: /var/log/myapp/app.log     # exact file (console output continues too)
  logback:
    rollingpolicy:
      max-file-size: 50MB            # roll when a file reaches this size
      max-history: 14                # keep 14 days of archives
      total-size-cap: 2GB            # hard ceiling across all archives
      file-name-pattern: ${LOG_FILE}.%d{yyyy-MM-dd}.%i.gz   # gzip old files

The instant you configure a file, Logback's rolling policy kicks in, and Boot exposes its knobs under logging.logback.rollingpolicy.*. Do not skip max-history and total-size-cap. An unbounded log file grows until it fills the disk, and a full disk is a classic 3am outage that takes the whole app down with it. Set the ceilings once and forget about them.

Customizing with logback-spring.xml

Properties cover levels, files, and rotation. When you need more — custom appenders, conditional config, environment-specific behavior — you graduate to an XML file. Here's the subtle but important bit: name it logback-spring.xml, not logback.xml. Plain logback.xml is read by Logback very early, before Spring's environment even exists, so Spring's extensions don't work. The -spring variant is loaded by Boot itself, which means you get <springProfile> and <springProperty>:

<configuration>
    <!-- start from Boot's sensible console/file defaults -->
    <include resource="org/springframework/boot/logging/logback/base.xml"/>

    <!-- pull a value from Spring's Environment into a Logback variable -->
    <springProperty scope="context" name="appName" source="spring.application.name"/>

    <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 config behave differently per environment — verbose, colored console in dev; lean file or JSON output in prod — instead of juggling separate files. <springProperty> injects any property from application.yml, env vars, or the command line into your Logback setup, so your appender paths and names stay in one source of truth.

Structured JSON for log aggregation

Pretty console text is for humans. Once your logs flow into Elasticsearch, Loki, or Splunk, you want machine-parseable JSON so every field — level, logger, MDC keys, stack traces — is indexed and queryable. Since Spring Boot 3.4 this is built in, no extra library. Point the format at a known schema and Boot emits one JSON object per line:

logging:
  structured:
    format:
      console: ecs        # Elastic Common Schema JSON on stdout
      file: logstash      # a different schema for the file appender
  file:
    name: /var/log/myapp/app.json

ecs, logstash, and gelf are supported out of the box, and you can register a custom format if your aggregator wants a particular shape. Before 3.4 you'd add logstash-logback-encoder and hand-configure an encoder; now it's a one-line property. The practical move is to switch the console appender to structured JSON whenever logs are centrally collected — the platform reads the JSON, and you read the aggregator's UI.

Changing levels at runtime

Some bugs only show up in production, and you don't want to redeploy just to add a DEBUG line. The actuator /loggers endpoint lets you change a logger live. A GET shows current levels; a POST with a configuredLevel body changes one on the fly:

# Inspect, then raise to DEBUG without a restart
curl localhost:8080/actuator/loggers/com.example.OrderService

curl -X POST localhost:8080/actuator/loggers/com.example.OrderService \
     -H 'Content-Type: application/json' \
     -d '{"configuredLevel":"DEBUG"}'
# POST {"configuredLevel": null} later to reset it to the inherited level

You need to expose it (management.endpoints.web.exposure.include=loggers) and, crucially, secure it — it changes runtime behavior, so it has no business being open to the world. Used carefully, it's the safest way to get DEBUG output during a live incident: flip the one package you care about, capture the logs, then set it back to null.

MDC and correlation IDs

In a busy service, logs from many concurrent requests interleave. To follow a single request through the noise, stamp every line with a correlation ID using the MDC (Mapped Diagnostic Context) — a per-thread key/value map SLF4J maintains, whose values you print with %X{key}:

// In a servlet filter, at the start of each request
MDC.put("traceId", request.getHeader("X-Trace-Id"));   // or a generated UUID
try {
    chain.doFilter(request, response);                 // every log now carries traceId
} finally {
    MDC.clear();   // CRITICAL: pooled threads are reused — clear or context leaks
}
# Reference the MDC key in your pattern
logging.pattern.level=%5p [%X{traceId:-}]

If you use Micrometer Tracing, Boot populates traceId and spanId in the MDC for you. The one rule you can never break: clear the MDC in a finally, because servlet threads are pooled and reused, and a leftover correlation ID will silently attach itself to the next, unrelated request.

Best practices worth internalizing

A few habits separate clean logs from a liability. Always use SLF4J's {} placeholders rather than string concatenation — the message is only built if the level is enabled, and for genuinely expensive arguments guard with if (log.isDebugEnabled()). Pick levels deliberately: ERROR for things that need a human, WARN for recoverable trouble, INFO for milestones, DEBUG/TRACE for diagnostics. Treat every log line as public and permanent — never log passwords, tokens, full card numbers, or PII. And lean on Lombok's @Slf4j to generate the log field so you stop copy-pasting LoggerFactory lines. When you need to diagnose startup, remember --debug is not root=DEBUG: it enables DEBUG on a curated set of core loggers and prints the auto-configuration report, which is exactly what you want when a bean mysteriously didn't get wired.

Recap

Spring Boot hands you a complete logging stack — SLF4J in front of Logback — with zero configuration, and most tuning is just properties: levels, groups, a file path, and rotation limits. Graduate to logback-spring.xml when you need profiles and injected properties, switch the console to structured JSON once logs flow into an aggregator, and keep the actuator /loggers endpoint handy (and secured) for runtime level changes. Stamp requests with an MDC correlation ID and clear it in a finally, use {} placeholders everywhere, keep secrets out of the logs, and bound your files so they can never fill the disk. Get those right and your logs become the first tool you trust when something breaks.

More ways to practice

The self-quiz is live. Join our channel for updates, new content & tech tips.

Join our WhatsApp Channel