Spring Boot Logging: JSON Is One Property, and the Guard You Still Need
Structured JSON logging is now one property — logging.structured.format.console=ecs — with no extra dependency and no logback-spring.xml, which is what most advice still tells you to write. Put request context in MDC and its keys arrive as top-level JSON fields. And placeholders do not replace the isDebugEnabled guard: measured with DEBUG off, the placeholder form still evaluated its argument.
Most advice on this query predates Spring Boot being able to do the main thing itself. It tells you to add logstash-logback-encoder, write a logback-spring.xml, and wire up an encoder — and that is now one line in application.properties.
This article covers what to actually configure, and then one measurement that corrects the most repeated piece of SLF4J advice there is.
Everything below is output from the same Spring Boot 4.1.1 jar, run three times with one environment variable changed.
JSON logs are one property
The default line:
2026-09-11T07:09:41.396Z INFO 11 --- [shop] [main] com.example.shop.LogDemo : processing an order
Timestamp, level, PID, application name, thread, logger, message. Good for a human watching a terminal.
Now add one property:
logging.structured.format.console=ecs
Same jar, same log call:
{"@timestamp":"2026-09-11T07:10:12.344893714Z",
"log":{"level":"INFO","logger":"com.example.shop.LogDemo"},
"process":{"pid":11,"thread":{"name":"main"}},
"service":{"name":"shop","version":"0.0.1-SNAPSHOT"},
"message":"processing an order",
"ecs":{"version":"8.11"}}
No dependency added. No logback-spring.xml. The field names are Elastic Common Schema, so a log system that speaks ECS understands it without a parsing rule.
logstash is the other common target, and it is the same switch:
{"@timestamp":"...","@version":"1","message":"Started ShopApplication...",
"logger_name":"com.example.shop.ShopApplication","thread_name":"main",
"level":"INFO","level_value":20000}
Both formats are built in, along with gelf, and there is a matching property for file output.
Tip
Keep the human format locally and the structured one in production. A profile does it: put
logging.structured.format.console=ecsinapplication-prod.propertiesand leave the default alone everywhere else.
Why structure matters more than format
A human reads one line at a time. A log system reads a million and is asked questions — show me everything for order A-4471, which tenant is producing these errors.
The difference is whether those facts are fields or text. "orderId":"A-4471" is something you can filter and aggregate on. "processing order A-4471" is something you can grep, until the message wording changes and your saved search quietly stops matching.
And write it to stdout, not a file. A log file inside a container sits on a disk nobody is watching and vanishes with the container, while the same line on stdout is already where the collector is looking — which is the arrangement every container platform assumes, whether something simple is running it or a scheduler is.
MDC is the reason structure pays
logging.structured.format.console gives you fields for what the framework knows. MDC is how you add what your application knows:
MDC.put("orderId", order.id());
MDC.put("tenant", tenant.name());
log.info("processing an order");
The ECS line that produced:
{"@timestamp":"...","log":{"level":"INFO","logger":"com.example.shop.LogDemo"},
"message":"processing an order",
"orderId":"A-4471","tenant":"acme","ecs":{"version":"8.11"}}
orderId and tenant are top-level fields, beside message rather than inside it. Every subsequent log line in that unit of work carries them too, without being passed anything — which is the whole point: you tag once and the context follows.
MDC is thread-scoped, and that cuts both ways. A second log call after MDC.clear() carried neither key, which is the behaviour you want — and it is also why a thread returned to a pool with a stale MDC will attach somebody else's order id to unrelated lines. Clear it when the unit of work ends, or set it in a filter that also removes it.
Common mistake
Logging identifiers by concatenating them into the message and calling that structured logging.
log.info("processing order " + id)produces one field,message, containing a sentence. Nothing can filter on it.
Placeholders do not do what you were told
Here is the advice, and you have read it many times: use {} placeholders instead of string concatenation, and then you do not need an isDebugEnabled guard.
The first half is right. The second half is not, and it is measurable. With DEBUG disabled, a counter inside the method being passed:
// DEBUG is off for this logger
log.debug("placeholder form: {}", expensive());
after log.debug with a placeholder, expensive() ran 1 time(s)
One call. The log line was never written, and the work to produce its argument was done anyway — because expensive() is an ordinary Java expression in an ordinary argument position, and Java evaluates arguments before the call. SLF4J never gets the chance to skip it.
The two forms that do skip it:
if (log.isDebugEnabled()) { log.debug("guarded: {}", expensive()); } // 0 calls
log.atDebug().addArgument(() -> expensive()).log("supplier form: {}"); // 0 calls
after a guarded log.debug, expensive() ran 0 time(s)
after the fluent supplier form, expensive() ran 0 time(s)
So the placeholder saves you the string building, which is real and worth having. It does not save you the argument. Which gives a rule you can apply without thinking about it:
- Cheap arguments — an id, a name, a field you already hold → placeholder, no guard. The guard would be noise.
- Expensive arguments — a query, a serialisation, a large
toString()→ guard it, or pass a supplier withatDebug().
If this feels familiar, it is the same Java semantics that make Optional.orElse run a fallback it does not need. Arguments are evaluated; only a lambda defers.
The properties worth knowing
logging.level.org.hibernate.SQL=DEBUG
logging.level.com.example=DEBUG
logging.file.name=/var/log/shop.log
Levels are properties, not a configuration file — which means they can come from an environment variable or a config server like anything else. And you rarely want that last line in a container.
Better than redeploying to change a level: change it on the running process. That is what Actuator's loggers endpoint is for, and it takes effect on the next log call.
Two things not to log, and they are worth stating because both happen constantly: secrets — tokens, passwords, keys, anything from an Authorization header — and whole request bodies, which is how personal data ends up in a log retention system that was never designed to hold it. A REST handler logging the body it just accepted is the usual route in.
If you are setting a service up from scratch, creating the project already gives you SLF4J and Logback on the classpath — there is nothing to add, and the whole of this article is four properties and one habit.
Frequently asked questions
- Do I still need logstash-logback-encoder for JSON logs?
- Not since Spring Boot 3.4. Setting logging.structured.format.console to ecs, logstash or gelf produces JSON from the built-in support, with no dependency and no logback configuration file. Verified here by running the same jar three times and changing only that one value.
- Does a placeholder remove the need for isDebugEnabled?
- No, and this is the most repeated half-truth about SLF4J. The placeholder stops the message string being built, but the arguments are ordinary Java expressions and are evaluated before the call. Measured with DEBUG disabled, log.debug("x: {}", expensive()) still called expensive() once.
- When is the isDebugEnabled guard actually worth writing?
- When producing the argument costs something — a query, a serialisation, a large toString. For cheap values it is noise that makes the code harder to read. The fluent form log.atDebug().addArgument(() -> expensive()).log("x: {}") is the tidier answer: it passes a supplier, and measured zero calls with DEBUG off.
- What should go in MDC?
- Identifiers you will want to filter a million log lines by — a request id, a tenant, an order id — and nothing secret. In structured output they become top-level JSON fields rather than text buried in the message, which is the difference between a query and a grep. MDC is thread-scoped, so clear it when the unit of work ends.
- Should Spring Boot write log files in production?
- Usually not. Write to stdout and let the platform collect, ship and rotate. A file inside a container is on a disk nobody is watching and disappears with the container; the same log on stdout is already where the collector is looking.
References
- Spring Boot Reference: LoggingSpring
- MDC (SLF4J API)SLF4J
- Frequently Asked QuestionsSLF4J
- Elastic Common SchemaElastic