Learn Java

Lesson 66Phase 8: Tools of the trade

Logging instead of println

Recording what a program does with levels and loggers, using java.util.logging and SLF4J with Logback.

By Updated 35 min

  • logging
  • Logger
  • levels
  • SLF4J
  • Logback
  • parameterized messages

Every program in this course talks to you through System.out.println. When something goes wrong, you add a few more printlns to see what is happening, and delete them once the bug is gone. That works for a program you run once. A real program runs for days on a machine you are not watching, and when it misbehaves you need a record of what it did, when, and where. This lesson swaps the stray printlns for logging: messages with a level, a time and a source, that you can turn up or down without touching the code.

By the end of this lesson
  • Say what println gets wrong as a record of what a program did, and what a logger adds.
  • Create a java.util.logging.Logger, log at the right level, and read the record it prints.
  • Explain why a FINE message can stay hidden after you set the logger to FINE, and fix it.
  • Write messages with parameters and with a Supplier, and log an exception with its stack trace.
  • Use SLF4J with Logback in a Maven project, and configure it with a logback.xml file.
  • Choose a level for a message, and name what must never go into a log.
From before
Maven and Gradle showed how a project gets a library from Maven Central, which is how SLF4J and Logback arrive. A logger lives in a private static final field, from static and final. Lazy messages are Supplier lambdas, from enums and lambdas. And a logged exception prints the stack trace you learned to read in annotations and tooling.

What println gets wrong#

Picture a shop app that has been running on a server for a week. A customer says their order vanished on Tuesday afternoon. You open the program's output and find thousands of lines like adding 250 and here!!, with no times, no idea which part of the program printed them, and the real output mixed in between. Debugging printlns cause each of those problems:

System.out.println
A logger
Every message looks the same, so a warning drowns among chatter.
Each message has a level, from SEVERE down to FINEST, and you choose how much to see.
No time and no source: you cannot tell when or where it was printed.
Each record carries the time, the class and the method.
Goes to the same place as the program's real output.
Goes to standard error by default, or to a file, or both.
To silence it you edit the code and rebuild.
You change one setting, even while the program is installed.
You delete it before shipping, then add it back for the next bug.
It stays in the code for good, switched off until you need it.

java.util.logging: the logger inside the JDK#

Java ships with a logging library in the package java.util.logging, so you can start without adding anything. Each class gets its own Logger, usually named after the class, kept in a private static final field. Run this and look at the console: the log lines appear in red, because they go to standard error, and Welcome!is the program's real output.

Shop.java
1import java.util.logging.Logger;
2
3public class Main {
4 private static final Logger log = Logger.getLogger(Main.class.getName());
5
6 public static void main(String[] args) {
7 log.info("Opening the shop");
8 int stock = 2;
9 if (stock < 3) {
10 log.warning("Stock is low: " + stock + " left");
11 }
12 log.fine("Checked the stock");
13 System.out.println("Welcome!");
14 }
15}
Prints
!Oct 26, 2026 8:21:21 AM Main main
!INFO: Opening the shop
!Oct 26, 2026 8:21:21 AM Main main
!WARNING: Stock is low: 2 left
›Welcome!

Each message becomes a log record of two lines. The first has the date and time, then the class and the method that logged it, Main main. The second has the level and the message, INFO: Opening the shop. Main.class.getName()is the class's full name, so in a class inside a package the logger would be called something like com.example.shop.Main. And the fine message printed nothing at all. That is levels at work.

Seven levels#

LevelMethodUse it for
SEVERElog.severe(...)Something failed and a person needs to know: a payment did not go through, the database is unreachable.
WARNINGlog.warning(...)Something unexpected that the program handled: stock is low, a setting was missing and the default was used.
INFOlog.info(...)The milestones of a normal run: the shop opened, 1,234 books loaded, an order was placed.
CONFIGlog.config(...)The settings the program started with.
FINE, FINER, FINESTlog.fine(...)Detail for developers chasing a bug, from fine to finest. Hidden normally.

A logger prints a message only if its level is at least the logger's own level. Out of the box that is INFO, so SEVERE, WARNING and INFO appear and everything below is dropped. The dropped messages cost almost nothing, which is why they can stay in the code.

The two gates: logger and handler#

To see the FINE messages while you hunt a bug, the obvious move is log.setLevel(Level.FINE). Try it, and watch the first fine call vanish anyway:

Gates.java
1import java.util.logging.ConsoleHandler;
2import java.util.logging.Level;
3import java.util.logging.Logger;
4
5public class Main {
6 private static final Logger log = Logger.getLogger("shop");
7
8 public static void main(String[] args) {
9 log.setLevel(Level.FINE);
10 log.fine("1: the logger lets FINE in, but the handler does not");
11
12 ConsoleHandler handler = new ConsoleHandler();
13 handler.setLevel(Level.FINE);
14 log.addHandler(handler);
15 log.setUseParentHandlers(false);
16
17 log.fine("2: now both gates let FINE through");
18 log.finer("3: FINER is below the logger's level");
19 }
20}
Prints
!Oct 26, 2026 8:21:21 AM Main main
!FINE: 2: now both gates let FINE through

Only message 2 is printed. A record passes two gates. The logger decides whether a message is worth recording at all; a handler decides whether to write it out, and where. The handler that prints to the console belongs to the root logger, the parent of every logger, and it has its own level, also INFO. Message 1 got through the first gate and was stopped at the second. The fix gives the logger its own ConsoleHandler set to FINE, and setUseParentHandlers(false)stops the record from also going to the root's handler, which would print it a second time once it allowed FINE.

Key idea

A log record is printed only if the logger's level and the handler's level both let it through. Raising one of them alone changes nothing.

Messages that cost nothing when they are dropped#

Building a message has a cost. log.fine("Cart: " + cart) joins the strings and calls cart.toString() before fine even starts, and then the record is dropped. Two forms avoid the waste. A message with numbered placeholders, {0}, {1}, is only filled in if the record is printed. A Supplier lambda is only called if the record is printed. Step through and watch the counter.

Messages.java
1import java.util.logging.Level;
2import java.util.logging.Logger;
3
4public class Main {
5 private static final Logger log = Logger.getLogger("shop");
6 static int calls = 0;
7
8 public static void main(String[] args) {
9 int books = 1234;
10 log.log(Level.INFO, "Loaded {0} books from {1}", new Object[]{books, "library.txt"});
11 log.log(Level.INFO, "Average price {0}", 12.3456);
12 log.fine(() -> "Report: " + slowReport());
13 System.out.println("slowReport ran " + calls + " times");
14 }
15
16 static String slowReport() {
17 calls++;
18 return "lots of detail";
19 }
20}
Prints
!Oct 26, 2026 8:21:21 AM Main main
!INFO: Loaded 1,234 books from library.txt
!Oct 26, 2026 8:21:21 AM Main main
!INFO: Average price 12.346
›slowReport ran 0 times

The log says Loaded 1,234 books from library.txt and Average price 12.346, and the output says slowReport ran 0 times: the fine record was dropped, so its lambda never ran. The placeholders are filled by java.text.MessageFormat, which formats numbers for reading. It adds the comma in 1,234 and rounds the price to three decimal places.

A single quote eats your placeholder
To MessageFormat, a single quote starts a piece of text to copy as it is. So log.log(Level.INFO, "Can't find {0}", "Dune") logs Cant find {0}: the quote vanished and so did Dune. Write two single quotes, Can''t find {0}, to get one. (A message with no parameters is printed exactly as written, quote and all.)
Predict the outputLazy.java
1import java.util.logging.Logger;
2
3public class Main {
4 private static final Logger log = Logger.getLogger("shop");
5 static int calls = 0;
6
7 public static void main(String[] args) {
8 log.fine("A: " + report());
9 log.fine(() -> "B: " + report());
10 log.info(() -> "C: " + report());
11 System.out.println(calls);
12 }
13
14 static String report() {
15 calls++;
16 return "details";
17 }
18}

The logger is at the default level, INFO. How many times does report() run?

Twice. Line A builds its string, calling report(), before finecan say the record is dropped. Line B hands over a lambda that is never called. Line C's lambda is called, because INFO is printed.

Logging an exception#

When you catch an exception and carry on, log it with the exception as the last argument. The record then includes the full stack trace, so the log answers “where did this come from?” long after the program has moved on.

Prices.java
1import java.util.logging.Level;
2import java.util.logging.Logger;
3
4public class Main {
5 private static final Logger log = Logger.getLogger("shop");
6
7 public static void main(String[] args) {
8 int[] prices = {450, -200, 320};
9 int total = 0;
10 for (int p : prices) {
11 try {
12 total += checked(p);
13 } catch (IllegalArgumentException e) {
14 log.log(Level.WARNING, "Skipping a bad price", e);
15 }
16 }
17 System.out.println("Total: " + total);
18 }
19
20 static int checked(int price) {
21 if (price < 0) {
22 throw new IllegalArgumentException("price cannot be negative: " + price);
23 }
24 return price;
25 }
26}
Prints
!Oct 26, 2026 8:21:21 AM Main main
!WARNING: Skipping a bad price
!java.lang.IllegalArgumentException: price cannot be negative: -200
! at Main.checked(Main.java:22)
! at Main.main(Main.java:12)
›
›Total: 770

The program prints Total: 770 and logs a WARNING with the exception and its trace under it: at Main.checked(Main.java:22), then at Main.main(Main.java:12). Logging e.getMessage() alone would lose those lines, and they are the most useful part.

System.Logger: the JDK's front door#

Java 9 added a smaller logging interface, System.Logger, that you get from System.getLogger(name). It has one method, log, with its own five levels, and it hands every record to a logging library: java.util.loggingby default, or another one, such as SLF4J, when that library's bridge for System.Logger is on the classpath (for SLF4J, the slf4j-jdk-platform-logging module). Code written against it never has to change when the library does.

Front.java
1public class Main {
2 private static final System.Logger log = System.getLogger("shop");
3
4 public static void main(String[] args) {
5 log.log(System.Logger.Level.INFO, "Opening the shop");
6 log.log(System.Logger.Level.WARNING, "Only {0} copies of {1} left", 2, "Dune");
7 log.log(System.Logger.Level.DEBUG, "Hidden: DEBUG means FINE underneath");
8 log.log(System.Logger.Level.ERROR, "The till is broken");
9 }
10}
Prints
!Oct 26, 2026 8:21:21 AM Main main
!INFO: Opening the shop
!Oct 26, 2026 8:21:21 AM Main main
!WARNING: Only 2 copies of Dune left
!Oct 26, 2026 8:21:21 AM Main main
!SEVERE: The till is broken

Three records appear. DEBUG becomes FINE in java.util.logging, which is dropped at the default level, and ERROR is printed as SEVERE, the name java.util.logging uses. The parameters work the same way, through MessageFormat.

SLF4J and Logback: what most projects use#

Look inside almost any Java project from a company and you will find a different pair: SLF4J (the Simple Logging Facade for Java) and Logback. They split the job in two, the way an interface and its implementation do in interfaces. SLF4J is the interface your code calls. Logback is the library that does the work: formatting, writing to the console and to files. A team can swap Logback for another implementation without changing a line of the code that logs.

They come from Maven Central like any library. Adding Logback is enough, because it brings the SLF4J interface with it:

pom.xml (the dependency)
read along
1<dependency>
2 <groupId>ch.qos.logback</groupId>
3 <artifactId>logback-classic</artifactId>
4 <version>1.6.5</version>
5</dependency>
src/main/java/com/example/shop/Main.java
read along
1package com.example.shop;
2
3import org.slf4j.Logger;
4import org.slf4j.LoggerFactory;
5
6public class Main {
7 private static final Logger log = LoggerFactory.getLogger(Main.class);
8
9 public static void main(String[] args) {
10 log.info("Opening the shop");
11 int books = 1234;
12 String file = "library.txt";
13 log.info("Loaded {} books from {}", books, file);
14 log.debug("Checking stock levels");
15 log.trace("Too detailed to show");
16 log.warn("Only {} copies of {} left", 2, "Dune");
17 try {
18 throw new IllegalStateException("drawer stuck");
19 } catch (IllegalStateException e) {
20 log.error("Could not open the till", e);
21 }
22 }
23}

It reads much like java.util.logging, with three differences you can see. The levels are trace, debug, info, warn and error. Placeholders are plain {}, filled in order, and only when the record is printed. And an exception goes last, after the placeholders' values. Run it with the Exec plugin:

Terminal (Maven 3.10.0, Logback 1.6.5)
read along
1$ mvn compile exec:java -Dexec.mainClass=com.example.shop.Main
2...
3[INFO] --- exec:3.6.4:java (default-cli) @ shop ---
415:34:50.736 [com.example.shop.Main.main()] INFO com.example.shop.Main -- Opening the shop
515:34:50.736 [com.example.shop.Main.main()] INFO com.example.shop.Main -- Loaded 1234 books from library.txt
615:34:50.736 [com.example.shop.Main.main()] DEBUG com.example.shop.Main -- Checking stock levels
715:34:50.736 [com.example.shop.Main.main()] WARN com.example.shop.Main -- Only 2 copies of Dune left
815:34:50.737 [com.example.shop.Main.main()] ERROR com.example.shop.Main -- Could not open the till
9java.lang.IllegalStateException: drawer stuck
10 at com.example.shop.Main.main(Main.java:18)
11 ...
12[INFO] BUILD SUCCESS

With no settings file, Logback prints one line per record: the time to the millisecond, the thread in brackets, the level, the logger's name, and the message. Its default level is DEBUG, so the debug line shows and the trace line does not. The thread is called com.example.shop.Main.main() because the Exec plugin starts the program on a thread of its own with that name; run it with java directly and it says main. The number is 1234, with no comma: SLF4J's {} uses plain toString, with none of MessageFormat's formatting and none of its quote trouble.

Configuring Logback with logback.xml#

Logback reads its settings from a file called logback.xml in src/main/resources, which Maven puts on the classpath. This one prints a shorter line to the console, writes a fuller one to a file called shop.log, keeps everything at INFO, and turns the shop's own code up to DEBUG:

src/main/resources/logback.xml
read along
1<configuration>
2 <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
3 <encoder>
4 <pattern>%d{HH:mm:ss} %-5level %logger{20} - %msg%n</pattern>
5 </encoder>
6 </appender>
7
8 <appender name="FILE" class="ch.qos.logback.core.FileAppender">
9 <file>shop.log</file>
10 <encoder>
11 <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger - %msg%n</pattern>
12 </encoder>
13 </appender>
14
15 <logger name="com.example.shop" level="DEBUG"/>
16
17 <root level="INFO">
18 <appender-ref ref="CONSOLE"/>
19 <appender-ref ref="FILE"/>
20 </root>
21</configuration>
Terminal (the program's lines, with logback.xml)
read along
115:35:12 INFO c.example.shop.Main - Opening the shop
215:35:12 INFO c.example.shop.Main - Loaded 1234 books from library.txt
315:35:12 DEBUG c.example.shop.Main - Checking stock levels
415:35:12 WARN c.example.shop.Main - Only 2 copies of Dune left
515:35:12 ERROR c.example.shop.Main - Could not open the till
6java.lang.IllegalStateException: drawer stuck
7 at com.example.shop.Main.main(Main.java:18)
8 ...
  • An appender is Logback's word for a destination, what java.util.logging calls a handler. This file has two, the console and shop.log, and the root logger sends every record to both. The file holds the same records in the fuller pattern, with the date and the thread.
  • In the pattern, %d is the date and time, %-5level the level padded to five characters so the messages line up, %logger{20}the logger's name shortened to about 20 characters (com became c), %msg the message and %n a new line.
  • <logger name="com.example.shop" level="DEBUG"/>sets one package's level. Loggers are named after classes, and a setting for com.example.shop covers every logger whose name starts with it. So your own code logs DEBUG while the libraries you use stay at INFO and stay quiet.
Which one should you use?
In a program of your own with no libraries, java.util.logging or System.Logger needs nothing extra. In a Maven or Gradle project, and in any project that uses Spring Boot, SLF4J with Logback is what you will meet, and what Spring Boot sets up for you.

What to log, and what never to log#

A log is only as useful as what you put in it. A few habits make the difference between a log that answers questions and a log nobody reads.

  • Log events, one record each, in a sentence a stranger could understand: Order 5521 placed by customer 873 for 3 items, not here or ok.
  • Include the ids that let someone find the thing later: the order number, the customer number, the file name.
  • Pick the level for the reader. If a person must act, it is ERROR (or SEVERE). If the program coped, WARN. If it is a normal milestone, INFO. Everything a developer wants only while debugging is DEBUG or below.
  • Log an exception once, where you handle it, with the exception attached. Logging it and then throwing it again means the same error appears in the log two or three times as it travels up.
Never log secrets or personal data
Logs are copied, kept for months and read by many people. Never log passwords, payment card numbers, access tokens or keys, and keep personal details such as addresses and phone numbers out unless you truly need them. Log the customer's id, not their whole record. A password that reaches a log file has to be treated as leaked.
Quick check
The payment service is down, and the shop cannot take any card payments until someone restarts it. Which level should that record have?
ExerciseCart.java
This cart prints its debugging notes with println, so they get mixed into the real output. Turn the “Adding” note into a FINE log message and the big-order note into a WARNING, using the logger that is already there, so the program's output is only the total.

Try it

Answer each one in your head or on paper first, then open the answer.

1
A teammate sets log.setLevel(Level.FINEST) on their logger to see every detail, but still sees only INFO and above. What is going on, and what are two ways to fix it?
2
Why is log.fine(() -> "Cart: " + cart) better than log.fine("Cart: " + cart) when FINE is switched off?
3
What does log.log(Level.INFO, "Don't sell {0}", "Dune") print with java.util.logging, and with SLF4J what does log.info("Don't sell {}", "Dune") print?
4
A method catches an IOException, logs it with log.error("Save failed", e), and then throws it again. Its caller does the same, and so does the caller's caller. What does the log look like, and what is the better habit?

Practice

Work through these on your own. Each one builds on this page.

1
Take a program you wrote for an earlier lesson that has debugging printlns in it. Replace each one with a logger call at a sensible level, run it with the default settings, and then with a logging.properties file that shows FINE for your class only. Write down which messages appear each time.
2
In a Maven project, add Logback, log at every level from trace to error, and write a logback.xml that sends WARN and above to a file called problems.log while the console shows INFO and above.
3
Look at the log lines below and rewrite each one so it is useful to a stranger reading the log a week later, at the right level, and safe to keep: log.info("here"), log.info("user " + user) where user holds a name, email and password, log.error("oops") in a catch block, and log.info("i=" + i) inside a loop of a million items.

Further reading#

Spotted a mistake, or a part that did not make sense? Report it by email