By WebNest Studio Engineering Team
Spring Boot Tutorial
Logging in Spring Boot
Print statements do not scale for real applications — you need log levels, structured output, and the ability to turn verbosity up or down per package without redeploying code. Spring Boot ships with a logging abstraction (SLF4J as the facade, Logback as the default implementation) already configured, so logging works out of the box with sensible console output.
SLF4J as the Logging Facade
SLF4J (Simple Logging Facade for Java) provides a common logging API that decouples your code from a specific logging implementation. You write against the SLF4J Logger interface, and Spring Boot wires up Logback underneath by default — meaning you could swap in Log4j2 later without changing a single log statement in your code.
Log Levels
Logging is organized by severity level, from most to least verbose: TRACE, DEBUG, INFO, WARN, ERROR. Setting a level for a logger (or package) means that level and everything more severe will be emitted; more verbose levels are suppressed. Spring Boot defaults the root logger to INFO, which is why routine framework startup messages appear but detailed debug traces do not, unless you explicitly raise verbosity.
Configuring Log Levels per Package
Log levels are configured through properties using the logging.level.* prefix, letting you set different verbosity for different parts of the application — for example, keeping your own code at DEBUG while leaving noisy third-party libraries at WARN. This is far more useful than a single global level, especially when diagnosing an issue in one specific layer (e.g. Hibernate SQL logging) without flooding logs from everything else.
Reading a Default Log Line
Spring Boot 4 prints each console line in a fixed layout. Knowing the fields makes it much faster to scan a log during an incident:
2026-10-10T14:50:55.817+05:30: an ISO-8601 timestamp with milliseconds and the UTC offset.INFO: the level, padded to five characters so the columns line up.25764: the process ID, useful when several instances write to the same machine.[webnest-orders]: the value ofspring.application.name. It is left out when the property is not set.[nio-8093-exec-2]: the thread name, cut to its last 15 characters. Request threads in Tomcat are calledhttp-nio-<port>-exec-N.demo.log.OrderService: the logger name, normally the class. Long names are shortened to fit 40 characters (for exampleo.s.web.servlet.DispatcherServlet).- Everything after the colon is your message.
Logging Groups
Real applications spread one feature over several packages: a controller package, a service package, a repository package. Listing each one under logging.level gets tedious, and you have to remember all of them when something breaks. A logging group gives a set of loggers one name: logging.group.orders=com.webnest.orders.web,com.webnest.orders.service. After that, logging.level.orders=DEBUG changes all of them at once. Spring Boot ships two groups of its own: web (Spring MVC, codecs, HTTP logging) and sql (Spring JDBC, jOOQ and Hibernate SQL), so logging.level.sql=DEBUG is the quickest way to see the SQL your application runs.
Adding Request Context with MDC
When fifty requests run at the same time, their log lines interleave and you can no longer tell which line belongs to which customer. SLF4J's MDC (Mapped Diagnostic Context) solves this. MDC.put("customerId", "42") attaches a value to the current thread, and the logging pattern can print it on every line that thread writes until you remove it. Spring Boot reserves a slot for this in its default layout: logging.pattern.correlation is printed between the thread name and the logger name. Setting it to [%X{customerId:-}] prints the customer ID in brackets, or empty brackets when none is set. Always remove the key in a finally block, because Tomcat reuses threads and a leftover value would be printed on a different customer's request. If you add Micrometer Tracing, Spring Boot fills the same slot with the trace and span IDs for you.
Writing Logs to a File
By default Spring Boot logs only to the console, which is right for containers: Docker and Kubernetes collect standard output for you. On a plain server you usually want a file as well. Set logging.file.name=logs/app.log (a full file path) or logging.file.path=/var/log/webnest (a folder; the file is called spring.log). The file uses the same layout as the console, except that the thread name is not cut short. Logback rolls the file over when it reaches 10 MB and keeps 7 days of archives by default, which you can change with logging.logback.rollingpolicy.max-file-size and logging.logback.rollingpolicy.max-history.
Structured JSON Logs
Log platforms such as Elasticsearch, Loki, Datadog and CloudWatch search JSON fields far better than free text. Since Spring Boot 3.4 you do not need an extra library for this: logging.structured.format.console=ecs (Elastic Common Schema), logstash or gelf switches the console to one JSON object per line. MDC values become top-level fields automatically, so customerId can be filtered on directly. A common setup is plain text in development and JSON in the prod profile. logging.structured.format.file does the same for the log file.
Changing Levels at Runtime with Actuator
Restarting a production instance just to see DEBUG output is slow, and the problem may disappear on restart. With Actuator's loggers endpoint exposed (and secured), you can read and change levels while the application runs. GET /actuator/loggers/orders shows a group's configured level and its members. POST the JSON {"configuredLevel":"DEBUG"} to the same URL to raise it; the response is HTTP 204 and the very next request logs at DEBUG. Posting {} clears the override so the logger falls back to its parent's level. The change lives only in memory, so it is lost on restart, which is usually what you want for a temporary investigation.
Examples
Using SLF4J logging with per-package level configuration
# application.properties
logging.level.root=INFO
logging.level.com.webnest.app=DEBUG
logging.level.org.hibernate.SQL=DEBUG
logging.level.org.springframework.web=WARN
---
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@Service
public class OrderService {
private static final Logger log = LoggerFactory.getLogger(OrderService.class);
public void placeOrder(Order order) {
log.debug("Processing order for customer {}", order.getCustomerId());
try {
// process
log.info("Order {} placed successfully", order.getId());
} catch (Exception ex) {
log.error("Failed to place order for customer {}", order.getCustomerId(), ex);
}
}
}
2026-10-10T10:32:11.502+05:30 DEBUG 8412 --- [webnest-app] [nio-8080-exec-1] com.webnest.app.OrderService : Processing order for customer 42
2026-10-10T10:32:11.548+05:30 INFO 8412 --- [webnest-app] [nio-8080-exec-1] com.webnest.app.OrderService : Order 1007 placed successfully
A logging group, MDC context and a log file (tested on Spring Boot 4.1.1)
# application.properties
spring.application.name=webnest-orders
logging.group.orders=demo.log
logging.level.orders=INFO
logging.pattern.correlation=[%X{customerId:-}]
logging.file.name=logs/app.log
management.endpoints.web.exposure.include=health,loggers
---
package demo.log;
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 String placeOrder(long customerId, int quantity) {
MDC.put("customerId", String.valueOf(customerId)); // printed on every line below
try {
log.debug("Validating order: quantity={}", quantity);
if (quantity <= 0) {
throw new IllegalArgumentException("quantity must be positive");
}
log.info("Order placed for {} items", quantity);
return "placed";
} catch (IllegalArgumentException ex) {
log.warn("Rejected order: {}", ex.getMessage());
return "rejected";
} finally {
MDC.remove("customerId"); // threads are reused
}
}
}
// A @RestController maps POST /orders?customer=..&quantity=.. to placeOrder.
POST /orders?customer=42&quantity=3 -> placed
POST /orders?customer=42&quantity=0 -> rejected
Console (and logs/app.log):
2026-10-10T14:50:16.607+05:30 INFO 24252 --- [webnest-orders] [ main] [] demo.App : Starting App v1 using Java 17.0.12 with PID 24252 (...)
2026-10-10T14:50:25.943+05:30 INFO 24252 --- [webnest-orders] [nio-8092-exec-2] [42] demo.log.OrderService : Order placed for 3 items
2026-10-10T14:50:26.018+05:30 WARN 24252 --- [webnest-orders] [nio-8092-exec-3] [42] demo.log.OrderService : Rejected order: quantity must be positive
(The DEBUG "Validating order" line is not printed because the orders group is at INFO.
Startup lines show [] because no customerId is in the MDC on the main thread.)
Raising a group to DEBUG at runtime through /actuator/loggers
# Read the current level of the "orders" group
curl localhost:8080/actuator/loggers/orders
# Switch it to DEBUG without restarting
curl -X POST -H "Content-Type: application/json" \
-d '{"configuredLevel":"DEBUG"}' \
localhost:8080/actuator/loggers/orders
# Place another order, then remove the override again
curl -X POST "localhost:8080/orders?customer=7&quantity=2"
curl -X POST -H "Content-Type: application/json" -d '{}' \
localhost:8080/actuator/loggers/orders
{"configuredLevel":"INFO","members":["demo.log"]}
HTTP 204 (level changed)
2026-10-10T14:50:26.509+05:30 DEBUG 24252 --- [webnest-orders] [nio-8092-exec-2] [7] demo.log.OrderService : Validating order: quantity=2
2026-10-10T14:50:26.510+05:30 INFO 24252 --- [webnest-orders] [nio-8092-exec-2] [7] demo.log.OrderService : Order placed for 2 items
HTTP 204 (override removed)
GET /actuator/loggers/orders -> {"members":["demo.log"]}
Switching the console to JSON (Elastic Common Schema)
# application-prod.properties
logging.structured.format.console=ecs
# Other built-in formats: logstash, gelf
{"@timestamp":"2026-10-10T09:21:05.624294500Z","log":{"level":"INFO","logger":"demo.log.OrderService"},
"process":{"pid":21928,"thread":{"name":"http-nio-8093-exec-2"}},
"service":{"name":"webnest-orders","version":"1","node":{}},
"message":"Order placed for 3 items","customerId":"42","ecs":{"version":"8.11"}}
(One JSON object per line; shown wrapped here. The MDC value customerId
becomes its own field without any extra configuration.)
Common Mistakes
- Using System.out.println() instead of a proper logger, which loses log levels, timestamps, and the ability to redirect/filter output.
- Building log messages by string concatenation instead of SLF4J's parameterized {} placeholders, which wastes CPU formatting strings even when the log level would filter them out.
- Logging entire exception objects with ex.toString() instead of passing the exception as the last argument, which loses the full stack trace in the output.
- Leaving DEBUG or TRACE enabled globally in production, generating excessive log volume and potentially leaking sensitive request/response data.
- Calling MDC.put() without MDC.remove() in a finally block. Tomcat reuses request threads, so the value leaks onto later, unrelated requests.
- Logging passwords, tokens, card numbers or full request bodies. Logs are copied to many systems and kept for a long time; mask or leave out sensitive fields.
- Exposing /actuator/loggers without authentication. Anyone who can reach it can switch every logger to TRACE and flood the disk.
- Writing to a log file inside a container instead of standard output, so the platform's log collector never sees the lines and they vanish with the container.
Exercise
Using the lesson's Using SLF4J logging with per-package level configuration example, create a small implementation or design sketch that demonstrates Spring Boot uses SLF4J as a logging facade with Logback as the default implementation, working out of the box.. Add a test case or scenario that would catch Using System.out.println() instead of a proper logger, which loses log levels, timestamps, and the ability to redirect/filter output., and describe the expected result.
Show hints
- Start with SLF4J as the Logging Facade and compare your approach with Using SLF4J logging with per-package level configuration. Identify the decision or behavior the lesson recommends.
- Walk through your scenario step by step. Confirm that it follows Spring Boot uses SLF4J as a logging facade with Logback as the default implementation, working out of the box. and that it would catch the failure described by Using System.out.println() instead of a proper logger, which loses log levels, timestamps, and the ability to redirect/filter output..
Quick quiz
1. Which choice is a mistake to avoid in Logging in Spring Boot?
- A. Building log messages by string concatenation instead of SLF4J's parameterized {} placeholders, which wastes CPU formatting strings even when the log level would filter them out.
- B. Spring Boot uses SLF4J as a logging facade with Logback as the default implementation, working out of the box.
- C. Using System.out.println() instead of a proper logger, which loses log levels, timestamps, and the ability to redirect/filter output.
Show answer
C. Avoid this approach: Using System.out.println() instead of a proper logger, which loses log levels, timestamps, and the ability to redirect/filter output. The lesson recommends: Spring Boot uses SLF4J as a logging facade with Logback as the default implementation, working out of the box.
Interview questions
A teammate is about to make this mistake: Using System.out.println() instead of a proper logger, which loses log levels, timestamps, and the ability to redirect/filter output. How would you diagnose and correct it using the lesson's guidance?
Show answer
Spring Boot uses SLF4J as a logging facade with Logback as the default implementation, working out of the box. Apply this in SLF4J as the Logging Facade; the example Using SLF4J logging with per-package level configuration shows one way to do it. Avoid Using System.out.println() instead of a proper logger, which loses log levels, timestamps, and the ability to redirect/filter output. and explain how your implementation or design prevents that failure.
Key Points to Remember
- Spring Boot uses SLF4J as a logging facade with Logback as the default implementation, working out of the box.
- Log levels (TRACE, DEBUG, INFO, WARN, ERROR) control verbosity, with INFO as the default root level.
- logging.level.<package>=LEVEL configures verbosity independently per package; logging.group.<name> lets one property control several packages, and web and sql groups are built in.
- Use parameterized logging ("{}", value) instead of string concatenation, and pass exceptions as the final argument to preserve the stack trace.
- MDC adds request context such as a customer or correlation ID to every line; logging.pattern.correlation prints it in the default layout.
- logging.file.name or logging.file.path adds a rolling log file (10 MB per file, 7 days of history by default).
- logging.structured.format.console=ecs|logstash|gelf produces JSON logs with no extra dependency.
- /actuator/loggers reads and changes levels at runtime; the change lasts until the next restart.
Practice in the playground
Write and run your own code for this topic, or start from the exercise or an example. Runs plain Java 17. Frameworks such as Spring, and databases, are not available here. Your code for each topic is kept in this browser.
Run the examples
Change an input, predict the result, then compare it with the output. Explain why the result changes.
These examples need a JDK project with their libraries on your computer, so they can be copied but not run here. Codelab runs Python, FastAPI backends with SQLite, plain Java 17 and HTML/CSS/JavaScript.
Written by
WebNest Studio Engineering Team
Practical lessons from developers working with Java, Python, React, databases and production systems.
Led by Vansh Duggal, Founder, WebNest Studio · Full-stack & software engineering