Java Logging: A Complete Guide tojava.util.logging

Learn how to use Java's built-in logging framework to record meaningful information about your program's behavior, debug issues in production, and maintain a reliable record of application events.

Why Logging Matters

Many beginners rely on System.out.println() to understand what their program is doing. While this works for small exercises, it breaks down quickly in real applications:

  • You can't selectively turn output on or off. Every println statement runs every time, cluttering your console and slowing your program down.
  • You can't route output to different destinations. println always goes to the console. In production, you need log files, and sometimes you need both console output and file output simultaneously.
  • You can't filter by severity. A debug message looks the same as a critical error in the console, making it hard to spot real problems.
  • You can't control the format. There's no timestamp, no class name, no log level — just raw text.

Java's built-in java.util.logging package (often abbreviated as JUL) solves all of these problems. It ships with every JDK, requires no external dependencies, and provides a flexible system for recording messages at different severity levels, routing them to different destinations, and formatting them consistently.

About This Tutorial

This tutorial covers java.util.logging in depth because it is the framework built into every Java installation. At the end, we briefly discuss other popular logging frameworks (SLF4J, Log4j 2) so you understand where JUL fits in the broader ecosystem.

Understanding Log Levels

Every log message has a level that indicates its importance. Java defines seven levels in the java.util.logging.Level class, ordered from most severe to least severe:

Log Levels (from most to least severe)
Level.SEVERE    // 1000 — Critical failures (e.g., database unreachable)
Level.WARNING  //  900 — Potential problems (e.g., deprecated API usage)
Level.INFO     //  800 — Notable runtime events (e.g., "Server started on port 8080")
Level.CONFIG   //  700 — Configuration-related messages
Level.FINE     //  500 — Debugging information (general detail)
Level.FINER    //  400 — Finer-grained debugging (e.g., method entry/exit)
Level.FINEST   //  300 — Highly detailed tracing

// There are also two special levels:
Level.ALL      // Integer.MIN_VALUE — Enables all levels
Level.OFF      // Integer.MAX_VALUE — Disables all logging

The key concept is threshold filtering : each logger and each handler has a log level set on it. A message is only processed if its level is equal to or higher than the configured level. By default, Java's root logger is set to INFO , which means CONFIG , FINE , FINER , and FINEST messages are silently discarded unless you explicitly change the level.

A Common Source of Confusion

Beginners often add logger.fine("debug message") calls and see nothing in the console. This is not a bug — the default level is INFO , so FINE messages are filtered out. You need to lower the logger's level (and often the handler's level too) to see them. We cover how to do this later in this tutorial.

Creating a Logger

You obtain a logger by calling the static factory method Logger.getLogger() . The argument is a name , which is typically the fully qualified class name. This convention makes it easy to identify which class produced each log message.

BasicLogger.java
import java.util.logging.Logger;

public class BasicLogger {

    // Use the class name as the logger name — this is the standard convention
    private static final Logger logger = Logger.getLogger(BasicLogger.class.getName());

    public static void main(String[] args) {
        logger.info("Application started.");
        logger.warning("Disk space is running low.");
        logger.severe("Unable to connect to the database.");
    }
}
Console Output
Jul 15, 2025 10:23:45 AM BasicLogger main INFO: Application started. Jul 15, 2025 10:23:45 AM BasicLogger main WARNING: Disk space is running low. Jul 15, 2025 10:23:45 AM BasicLogger main SEVERE: Unable to connect to the database.

Notice the output format: each line includes a timestamp , the logger name (which class generated it), the log level , and the message . This is the default SimpleFormatter output, which is far more useful than raw println output.

Logger Instances Are Cached

Calling Logger.getLogger("com.example.MyClass") multiple times returns the same Logger object. This is why it's safe to store it in a static final field — there's no performance cost, and the reference stays valid for the lifetime of the application.

Logging Methods

The Logger class provides convenience methods for each standard level. Here are the ones you'll use most often:

LoggingMethods.java
import java.util.logging.Level;
import java.util.logging.Logger;

public class LoggingMethods {

    private static final Logger logger = Logger.getLogger(LoggingMethods.class.getName());

    public static void main(String[] args) {

        // Convenience methods — one for each standard level
        logger.severe("Critical error occurred.");
        logger.warning("Something unexpected happened.");
        logger.info("Normal operational message.");
        logger.config("Configuration value: timeout=30");
        logger.fine("Entered the processOrder() method.");
        logger.finer("Checking inventory for item ID: 1042.");
        logger.finest("Hash code of order object: 48291.");

        // Generic log() method — useful for custom levels
        logger.log(Level.WARNING, "This is equivalent to logger.warning().");

        // log() with an exception — automatically includes the stack trace
        try {
            int result = 10 / 0;
        } catch (ArithmeticException e) {
            logger.log(Level.SEVERE, "Division by zero detected.", e);
        }
    }
}
Console Output
Jul 15, 2025 10:30:12 AM LoggingMethods main SEVERE: Critical error occurred. Jul 15, 2025 10:30:12 AM LoggingMethods main WARNING: Something unexpected happened. Jul 15, 2025 10:30:12 AM LoggingMethods main INFO: Normal operational message. Jul 15, 2025 10:30:12 AM LoggingMethods main WARNING: This is equivalent to logger.warning(). Jul 15, 2025 10:30:12 AM LoggingMethods main SEVERE: Division by zero detected. java.lang.ArithmeticException: / by zero at LoggingMethods.main(LoggingMethods.java:24)

Several things to notice in the output above:

  1. The config , fine , finer , and finest messages did not appear because the default level is INFO .
  2. When you pass a Throwable as the third argument to log() , the full stack trace is printed automatically — this is extremely useful for error diagnosis.

Parameterized Logging

A common mistake is using string concatenation to build log messages:

Avoid: String Concatenation in Log Messages
// BAD — the string concatenation happens even if the log level
// filters out this message, wasting CPU and memory
logger.fine("Processing order " + orderId + " for customer " + customerName);

Instead, use parameterized logging , which defers string formatting until the framework confirms the message will actually be logged:

ParameterizedLogging.java
import java.util.logging.Logger;

public class ParameterizedLogging {

    private static final Logger logger = Logger.getLogger(ParameterizedLogging.class.getName());

    public static void main(String[] args) {
        String orderId = "ORD-7842";
        String customerName = "Alice Johnson";
        double total = 149.99;

        // GOOD — parameters are only substituted if the message is actually logged
        logger.log(Level.INFO, "Processing order {0} for customer {1}, total: ${2}",
                new Object[]{orderId, customerName, total});
    }
}
Console Output
Jul 15, 2025 10:35:00 AM ParameterizedLogging main INFO: Processing order ORD-7842 for customer Alice Johnson, total: $149.99

The {0} , {1} , {2} placeholders are replaced by the corresponding objects in the array. The toString() method of each object is called only if the log level permits the message to be processed. In a loop that runs thousands of times with FINE -level messages that are filtered out, this can make a significant performance difference.

Limitation of JUL Parameterized Logging

Unlike SLF4J's "{}" syntax, JUL's log() method with parameters requires passing an Object[] . If you have only one or two parameters, you can use the two-argument and three-argument overloads: logger.log(Level.INFO, "Order {0} received", orderId) . For more than three parameters, you must use the Object[] form shown above.

The Logger Hierarchy

Loggers in java.util.logging form a tree based on their names, using dot ( . ) as the separator — the same structure as Java packages. For example:

  • com is the parent of com.example
  • com.example is the parent of com.example.service
  • com.example.service is the parent of com.example.service.OrderService

At the top of this tree is the root logger , which you can access with Logger.getLogger("") (empty string) or Logger.getLogger(Logger.GLOBAL_LOGGER_NAME) .

Why does the hierarchy matter? Because loggers inherit configuration from their parent. If you set the root logger's level to FINE , then every logger in the application will process FINE messages — unless a specific logger overrides the level with its own setting.

LoggerHierarchy.java
import java.util.logging.Level;
import java.util.logging.Logger;

public class LoggerHierarchy {

    public static void main(String[] args) {

        // Get the root logger
        Logger rootLogger = Logger.getLogger("");

        // Lower the root logger's level so FINE messages pass through
        rootLogger.setLevel(Level.FINE);

        // Create a child logger — it inherits FINE level from the root
        Logger childLogger = Logger.getLogger("com.example.app");

        // This FINE message will now appear because of inheritance
        childLogger.fine("This message appears because the root level is FINE.");

        // Override the child's level to WARNING only
        childLogger.setLevel(Level.WARNING);

        // This FINE message is now filtered out by the child's own level
        childLogger.fine("This message will NOT appear.");

        // But this WARNING message still appears
        childLogger.warning("This WARNING message appears.");
    }
}

Practical Use of the Hierarchy

In a real application, you might set the root logger to INFO for normal operation, then lower just one package to FINE when debugging a specific area: Logger.getLogger("com.example.payment").setLevel(Level.FINE); . This lets you get detailed logs from the payment module without flooding the console with debug output from every other part of the system.

Handlers: Controlling Where Logs Go

A handler (also called a "publisher") determines the destination of log messages. Each logger can have zero or more handlers, and each handler has its own log level filter.

The two most commonly used built-in handlers are:

  • ConsoleHandler — writes log messages to System.err
  • FileHandler — writes log messages to a file (or set of rotating files)

ConsoleHandler

By default, the root logger has a single ConsoleHandler attached to it. Its default level is INFO . This is why you see INFO , WARNING , and SEVERE messages in the console but not FINE or below — even if you set the logger's level to FINE , the handler's level still filters them out.

ConsoleHandlerDemo.java
import java.util.logging.ConsoleHandler;
import java.util.logging.Level;
import java.util.logging.Logger;

public class ConsoleHandlerDemo {

    private static final Logger logger = Logger.getLogger(ConsoleHandlerDemo.class.getName());

    public static void main(String[] args) {

        // Step 1: Set the logger's level to FINE
        logger.setLevel(Level.FINE);

        // Step 2: Get the root logger's console handler and lower its level too
        Logger rootLogger = logger.getParent();
        if (rootLogger.getHandlers().length > 0) {
            rootLogger.getHandlers()[0].setLevel(Level.FINE);
        }

        // Now FINE messages will appear in the console
        logger.info("Server started on port 8080.");
        logger.fine("Loading configuration from application.properties.");
        logger.finer("Parsing key 'db.url' with value 'jdbc:mysql://localhost:3306/mydb'.");
    }
}
Console Output
Jul 15, 2025 10:45:00 AM ConsoleHandlerDemo main INFO: Server started on port 8080. Jul 15, 2025 10:45:00 AM ConsoleHandlerDemo main FINE: Loading configuration from application.properties.

Note that the FINER message still did not appear. That's because we set the handler to FINE FINER is below FINE in severity, so it gets filtered out. To see FINER and FINEST , set the level to FINER or FINEST (or ALL ).

Two-Level Filtering Is a Common Pitfall

Messages pass through two filters before reaching the console: first the logger's level, then the handler's level. Both must allow the message through. If you change the logger's level but not the handler's level (or vice versa), messages will still be silently dropped. Always set both when you need lower-level messages.

FileHandler

Writing logs to a file is essential for production applications. The FileHandler supports simple file output as well as log rotation — automatically starting a new file when the current one reaches a size limit.

FileHandlerDemo.java
import java.util.logging.FileHandler;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.logging.SimpleFormatter;
import java.io.IOException;

public class FileHandlerDemo {

    private static final Logger logger = Logger.getLogger(FileHandlerDemo.class.getName());

    public static void main(String[] args) throws IOException {

        // Remove the default console handler to avoid duplicate output
        Logger rootLogger = logger.getParent();
        for (var handler : rootLogger.getHandlers()) {
            rootLogger.removeHandler(handler);
        }

        // Create a FileHandler with rotation:
        // - Pattern: "app.log.0", "app.log.1", etc.
        // - Limit: 10,000 bytes per file
        // - Count: keep up to 3 log files
        // - Append: true = add to existing files, false = start fresh
        FileHandler fileHandler = new FileHandler("app.log", 10000, 3, true);
        fileHandler.setLevel(Level.ALL);

        // Use SimpleFormatter for human-readable text (XMLFormatter is the default)
        fileHandler.setFormatter(new SimpleFormatter());

        // Attach the file handler to our logger
        logger.addHandler(fileHandler);
        logger.setLevel(Level.ALL);

        // Log some messages
        logger.info("Application started.");
        logger.fine("Initializing database connection pool.");
        logger.warning("Configuration file not found, using defaults.");

        // Close the handler to flush and release the file
        fileHandler.close();

        System.out.println("Logs written to app.log");
    }
}
Console Output
Logs written to app.log

After running this program, you'll find an app.log.0 file in your project directory containing the log messages in the same format you'd see on the console.

FileHandler Pattern Syntax

The pattern string in the FileHandler constructor controls where files are written:

  • "app.log" — creates app.log.0 , app.log.1 , etc. in the working directory
  • "logs/app.log" — creates files in a logs/ subdirectory (the directory must exist)
  • "/var/log/myapp/app.log" — absolute path (on Linux/macOS)
  • "%h/app.log" — uses the user home directory
  • "%t/app.log" — uses the system temporary directory

Using Multiple Handlers

A single logger can send messages to multiple destinations simultaneously. This is a common pattern in production: you want INFO and above in a log file, but only WARNING and above on the console.

MultipleHandlers.java
import java.util.logging.*;

public class MultipleHandlers {

    private static final Logger logger = Logger.getLogger(MultipleHandlers.class.getName());

    public static void main(String[] args) throws Exception {

        // Clear default handlers from the root logger
        Logger root = logger.getParent();
        for (var h : root.getHandlers()) {
            root.removeHandler(h);
        }

        // Handler 1: Console — only WARNING and above
        ConsoleHandler consoleHandler = new ConsoleHandler();
        consoleHandler.setLevel(Level.WARNING);
        consoleHandler.setFormatter(new SimpleFormatter());

        // Handler 2: File — INFO and above, with rotation
        FileHandler fileHandler = new FileHandler("app.log", 50000, 5, true);
        fileHandler.setLevel(Level.INFO);
        fileHandler.setFormatter(new SimpleFormatter());

        // Attach both handlers to the root logger
        root.addHandler(consoleHandler);
        root.addHandler(fileHandler);
        root.setLevel(Level.ALL);

        logger.setLevel(Level.ALL);

        // These three messages go to the FILE only (below WARNING)
        logger.info("Application initialized.");
        logger.config("Max connections: 20");
        logger.fine("Loading user preferences from database.");

        // These two messages go to BOTH console and file
        logger.warning("Cache miss rate exceeds 40%.");
        logger.severe("Payment gateway returned HTTP 503.");

        fileHandler.close();
    }
}
Console Output (only WARNING and above)
Jul 15, 2025 11:00:00 AM MultipleHandlers main WARNING: Cache miss rate exceeds 40%. Jul 15, 2025 11:00:00 AM MultipleHandlers main SEVERE: Payment gateway returned HTTP 503.

Meanwhile, the app.log.0 file contains all five messages — the three INFO / CONFIG / FINE messages plus the two that also went to the console.

Formatters: Controlling How Logs Look

A formatter converts a LogRecord (the internal representation of a log message) into a string. Java provides two built-in formatters:

  • SimpleFormatter — produces human-readable text (the format you've seen in all examples above)
  • XMLFormatter — produces XML output, which is the default for FileHandler (this surprises many beginners)

FileHandler Defaults to XMLFormatter

If you create a FileHandler and don't explicitly set a formatter, you'll get XML in your log file — not the readable text format you see on the console. Always call fileHandler.setFormatter(new SimpleFormatter()) if you want human-readable log files.

Creating a Custom Formatter

You can create your own formatter by extending the Formatter class and overriding the format() method. Here's a practical example that produces a clean, one-line format:

CompactFormatter.java
import java.util.logging.Formatter;
import java.util.logging.LogRecord;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;

/**
 * A compact, single-line log formatter.
 *
 * Example output:
 *   2025-07-15 10:45:00 [INFO ] com.example.App - Server started on port 8080.
 *   2025-07-15 10:45:01 [WARN ] com.example.App - Disk space low: 2.1 GB remaining.
 *   2025-07-15 10:45:02 [ERROR] com.example.App - Database connection failed.
 */
public class CompactFormatter extends Formatter {

    private static final DateTimeFormatter DATE_FORMAT =
            DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
                    .withZone(ZoneId.systemDefault());

    @Override
    public String format(LogRecord record) {
        String timestamp = DATE_FORMAT.format(record.getInstant());
        String level = padLevel(record.getLevel().getName());
        String loggerName = record.getLoggerName();
        String message = record.getMessage();

        return String.format("%s [%s] %s - %s%n", timestamp, level, loggerName, message);
    }

    /**
     * Pad the level name to 5 characters so columns align.
     * "INFO" becomes "INFO ", "SEVERE" becomes "SEVER" (truncated).
     */
    private String padLevel(String level) {
        if (level.length() >= 5) {
            return level.substring(0, 5);
        }
        return String.format("%-5s", level);
    }
}

Using this custom formatter:

CustomFormatterDemo.java
import java.util.logging.*;

public class CustomFormatterDemo {

    private static final Logger logger = Logger.getLogger(CustomFormatterDemo.class.getName());

    public static void main(String[] args) {
        Logger root = logger.getParent();
        for (var h : root.getHandlers()) {
            h.setFormatter(new CompactFormatter());
            h.setLevel(Level.ALL);
        }
        root.setLevel(Level.ALL);
        logger.setLevel(Level.ALL);

        logger.info("Server started on port 8080.");
        logger.warning("Disk space low: 2.1 GB remaining.");
        logger.severe("Database connection failed.");
    }
}
Console Output
2025-07-15 10:50:00 [INFO ] CustomFormatterDemo main - Server started on port 8080. 2025-07-15 10:50:00 [WARN ] CustomFormatterDemo main - Disk space low: 2.1 GB remaining. 2025-07-15 10:50:00 [SEVER] CustomFormatterDemo main - Database connection failed.

Configuration with a Properties File

Setting up logging programmatically (as we've done above) works for small programs, but it has a major drawback: you have to recompile to change the logging configuration. In production, you want to adjust log levels and handlers by editing a configuration file and restarting the application — no recompilation needed.

Java's logging framework reads a configuration file when you call LogManager.readConfiguration() . Here's how to set it up:

logging.properties
# ============================================
# Java Logging Configuration File
# ============================================

# Global level for the root logger
# This filters messages BEFORE they reach any handler
.level=INFO

# --------------------------------------------
# Console Handler Configuration
# --------------------------------------------
handlers=java.util.logging.ConsoleHandler, java.util.logging.FileHandler

# Console handler: show WARNING and above only
java.util.logging.ConsoleHandler.level=WARNING
java.util.logging.ConsoleHandler.formatter=java.util.logging.SimpleFormatter

# --------------------------------------------
# File Handler Configuration
# --------------------------------------------
# Pattern: app.log.0, app.log.1, etc.
# Limit: 50000 bytes per file
# Count: keep 5 files
# Append: add to existing files
java.util.logging.FileHandler.pattern=app.log
java.util.logging.FileHandler.limit=50000
java.util.logging.FileHandler.count=5
java.util.logging.FileHandler.append=true
java.util.logging.FileHandler.level=ALL
java.util.logging.FileHandler.formatter=java.util.logging.SimpleFormatter

# --------------------------------------------
# Per-Package / Per-Class Level Overrides
# --------------------------------------------
# Enable detailed logging for the payment package only
com.example.payment.level=FINE

# Suppress noisy logging from a third-party library
com.thirdparty.noisy.level=WARNING
ConfigFileDemo.java
import java.io.FileInputStream;
import java.util.logging.LogManager;
import java.util.logging.Logger;

public class ConfigFileDemo {

    private static final Logger logger = Logger.getLogger(ConfigFileDemo.class.getName());

    public static void main(String[] args) throws Exception {

        // Load the configuration file BEFORE creating any loggers
        // (or at least before logging any messages)
        LogManager.getLogManager().readConfiguration(
            new FileInputStream("logging.properties")
        );

        // These messages are now controlled entirely by the properties file
        logger.info("Application started.");
        logger.fine("This goes to the file but not the console.");
        logger.warning("This goes to both file and console.");
    }
}

Load Configuration Early

Call LogManager.readConfiguration() as early as possible in your application — ideally in the first few lines of main() . If you create loggers before loading the configuration, those loggers may cache the default settings and not pick up your changes.

Fine-Grained Filtering with Filters

Log levels provide coarse filtering — a message either passes or it doesn't. If you need more nuanced control (e.g., "log WARNING messages but only if they come from the payment package"), you can attach a Filter to a logger or handler.

PackageFilter.java
import java.util.logging.Filter;
import java.util.logging.Level;
import java.util.logging.LogRecord;
import java.util.logging.Logger;

/**
 * A filter that only allows log records from a specific package prefix.
 */
public class PackageFilter implements Filter {

    private final String packagePrefix;

    public PackageFilter(String packagePrefix) {
        this.packagePrefix = packagePrefix;
    }

    @Override
    public boolean isLoggable(LogRecord record) {
        return record.getLoggerName().startsWith(packagePrefix);
    }
}
FilterDemo.java
import java.util.logging.*;

public class FilterDemo {

    public static void main(String[] args) {
        Logger root = Logger.getLogger("");
        root.setLevel(Level.ALL);

        ConsoleHandler handler = new ConsoleHandler();
        handler.setLevel(Level.ALL);
        handler.setFormatter(new SimpleFormatter());

        // Only allow messages from loggers whose name starts with "com.example"
        handler.setFilter(new PackageFilter("com.example"));

        for (var h : root.getHandlers()) {
            root.removeHandler(h);
        }
        root.addHandler(handler);

        // This logger's name starts with "com.example" — message passes the filter
        Logger appLogger = Logger.getLogger("com.example.app");
        appLogger.info("This message WILL appear.");

        // This logger's name does NOT start with "com.example" — blocked by filter
        Logger otherLogger = Logger.getLogger("org.apache.library");
        otherLogger.info("This message will NOT appear.");
    }
}
Console Output
Jul 15, 2025 11:10:00 AM FilterDemo main INFO: This message WILL appear.

The second message was silently dropped by the filter, even though both the logger level and the handler level were set to ALL .

Logging Best Practices

These guidelines apply regardless of which logging framework you use:

1. Use the Right Level for Each Message

  • SEVERE : Use for failures that require immediate attention — database down, out of memory, security breach detected. These should trigger alerts in production.
  • WARNING : Use for degraded but recoverable situations — retrying a failed request, falling back to a default value, using a deprecated feature.
  • INFO : Use for significant business events — "Order #1234 placed", "User alice@example.com logged in", "Batch job completed: 500 records processed". These tell the story of what the application is doing.
  • FINE/FINER/FINEST : Use for technical debugging details — variable values, method entry/exit, loop iterations. Enable these only when debugging.

2. Write Meaningful Messages

Bad vs. Good Log Messages
// BAD — vague, no context
logger.severe("Error happened.");
logger.info("Done.");

// GOOD — specific, actionable, includes context
logger.severe("Failed to process payment for order ORD-7842: "
        + "Gateway returned HTTP 503 after 3 retries.");
logger.info("Batch import completed: 500 records processed, "
        + "3 duplicates skipped, 0 errors.");

3. Never Log Sensitive Data

Passwords, API keys, credit card numbers, Social Security numbers, and personal identification information must never appear in log files. Log files are often stored in less-secure locations and may be accessible to more people than you expect.

Avoid: Logging Sensitive Information
// VERY BAD — password in the log file
logger.fine("Attempting login for user " + username
        + " with password " + password);

// GOOD — log the action without the secret
logger.info("Login attempt for user: " + username);

// GOOD — mask sensitive fields
logger.fine("Processing card ending in "
        + cardNumber.substring(cardNumber.length() - 4));

4. Avoid Excessive Logging in Hot Paths

A log statement inside a loop that runs millions of times will slow your program down, even if the messages are filtered out (the method call and level check still have a cost). Use isLoggable() to guard expensive operations:

Guarding Expensive Log Operations
// Without the guard, buildReport() is called even if FINE is disabled
logger.fine(buildExpensiveReport());  // BAD if buildExpensiveReport() is costly

// With the guard, the expensive method is only called when needed
if (logger.isLoggable(Level.FINE)) {
    logger.fine(buildExpensiveReport());
}

5. Use Parameterized Logging Instead of Concatenation

We covered this earlier in the Parameterized Logging section, but it's worth repeating: always prefer logger.log(Level.INFO, "Order {0} placed", orderId) over logger.info("Order " + orderId + " placed") . The former defers string construction until the message is confirmed to pass the level filter.

6. Don't Use Logging for Control Flow

Log messages should observe your program's behavior, not control it. Never check log output to make decisions in your code. Use exceptions, return values, or dedicated monitoring mechanisms for control flow.

Putting It All Together: A Real-World Example

Here's a complete example that demonstrates how you might set up logging in a realistic application class — an order processing service:

OrderService.java
import java.util.logging.Level;
import java.util.logging.Logger;

public class OrderService {

    private static final Logger logger = Logger.getLogger(OrderService.class.getName());

    private final PaymentGateway paymentGateway;
    private final InventoryService inventoryService;

    public OrderService(PaymentGateway paymentGateway,
                       InventoryService inventoryService) {
        this.paymentGateway = paymentGateway;
        this.inventoryService = inventoryService;
        logger.info("OrderService initialized.");
    }

    public OrderResult processOrder(Order order) {
        logger.info("Processing order {0} for customer {1}, total: ${2}",
                new Object[]{order.getId(), order.getCustomerId(), order.getTotal()});

        // Step 1: Check inventory
        if (logger.isLoggable(Level.FINE)) {
            logger.fine("Checking inventory for {0} items in order {1}.",
                    new Object[]{order.getItems().size(), order.getId()});
        }

        boolean inStock = inventoryService.reserveItems(order.getItems());

        if (!inStock) {
            logger.warning("Order {0} cannot be fulfilled: insufficient inventory.",
                    order.getId());
            return new OrderResult(OrderStatus.OUT_OF_STOCK, "Insufficient inventory.");
        }

        // Step 2: Process payment
        logger.fine("Initiating payment for order {0}.", order.getId());

        try {
            PaymentResult payment = paymentGateway.charge(order);
            logger.info("Payment approved for order {0}, transaction ID: {1}.",
                    new Object[]{order.getId(), payment.getTransactionId()});

            return new OrderResult(OrderStatus.CONFIRMED, "Order confirmed.");

        } catch (PaymentException e) {
            // SEVERE because payment failure is a critical business issue
            logger.log(Level.SEVERE,
                    "Payment failed for order {0}: {1}",
                    new Object[]{order.getId(), e.getMessage()});

            // Release the reserved inventory
            inventoryService.releaseItems(order.getItems());
            logger.info("Inventory released for order {0}.", order.getId());

            return new OrderResult(OrderStatus.PAYMENT_FAILED, "Payment failed.");
        }
    }
}

// --- Supporting types (simplified for demonstration) ---

class Order {
    private String id;
    private String customerId;
    private double total;
    private List<String> items;

    public String getId() { return id; }
    public String getCustomerId() { return customerId; }
    public double getTotal() { return total; }
    public List<String> getItems() { return items; }
}

enum OrderStatus { CONFIRMED, OUT_OF_STOCK, PAYMENT_FAILED }

class OrderResult {
    private final OrderStatus status;
    private final String message;
    public OrderResult(OrderStatus status, String message) {
        this.status = status; this.message = message;
    }
}

class PaymentGateway {
    public PaymentResult charge(Order order) throws PaymentException {
        return new PaymentResult("TXN-001");
    }
}

class PaymentResult {
    private final String transactionId;
    public PaymentResult(String transactionId) {
        this.transactionId = transactionId;
    }
    public String getTransactionId() { return transactionId; }
}

class PaymentException extends Exception {
    public PaymentException(String message) { super(message); }
}

class InventoryService {
    public boolean reserveItems(List<String> items) { return true; }
    public void releaseItems(List<String> items) { }
}

Notice the logging decisions in this example:

  • INFO for business-significant events (order received, payment approved, inventory released)
  • FINE for technical details (checking inventory, initiating payment) — only visible when debugging
  • WARNING for a recoverable business problem (out of stock)
  • SEVERE for a critical failure (payment exception)
  • Parameterized messages with Object[] throughout — no string concatenation
  • isLoggable() guard before the inventory detail log that constructs a message from a list
  • No sensitive data (no credit card numbers, no customer passwords)
Ad placeholder
Advertisement

Other Logging Frameworks and SLF4J

While java.util.logging is perfectly capable, many Java projects use other logging frameworks. Here's a brief overview of the landscape so you can make informed decisions:

Framework Type Key Strength
java.util.logging Implementation Built into the JDK, zero dependencies
Log4j 2 Implementation High performance, async logging, flexible configuration
java.util.logging (JUL) Implementation No external dependencies required
SLF4J Facade / API Lets you switch implementations without changing code

The key concept here is the difference between a logging facade and a logging implementation :

  • An implementation (JUL, Log4j 2, Logback) actually writes the log messages somewhere.
  • A facade (SLF4J) provides a clean API that your code calls, then delegates to whatever implementation you choose at deployment time.

When to Use What

  • Learning and small projects: java.util.logging is ideal — no setup, no dependencies, and you're learning concepts that transfer to every other framework.
  • Professional projects with a build system: Use SLF4J as your API and Log4j 2 or Logback as the implementation. This gives you better performance, cleaner syntax ( "{}" instead of {0} ), and the flexibility to swap implementations later.
  • Libraries you publish: Always use SLF4J (never bind to a specific implementation), so users of your library can choose their own logging backend.

The good news: everything you learned in this tutorial — log levels, handlers, formatters, filters, hierarchy, parameterized logging, best practices — applies directly to Log4j 2 and Logback. The API names differ slightly, but the concepts are identical.

Summary

Here's a quick reference of what we covered:

  • Log levels ( SEVERE > WARNING > INFO > CONFIG > FINE > FINER > FINEST ) control which messages are processed based on severity thresholds.
  • Loggers are named (typically by class name) and form a hierarchy based on dot-separated names. Children inherit configuration from parents.
  • Handlers ( ConsoleHandler , FileHandler ) determine where log messages go. Each handler has its own level filter.
  • Formatters ( SimpleFormatter , XMLFormatter , or custom) control how log messages are formatted as text.
  • Filters provide fine-grained control beyond simple level filtering.
  • Configuration files let you change logging behavior without recompiling.
  • Best practices : use the right level, write meaningful messages, never log sensitive data, use parameterized logging, guard expensive operations with isLoggable() .
Exercise

Exercise: Build a Configurable Logger Utility

Create a LoggingConfig class that:

  1. Reads a logging configuration from a properties file.
  2. Sets up a ConsoleHandler with SimpleFormatter and the level specified in the property console.level (default: INFO ).
  3. Sets up a FileHandler with SimpleFormatter and the level specified in file.level (default: ALL ). The file pattern, size limit, and file count should also come from properties.
  4. Allows setting per-package levels via properties like level.com.example.service=FINE .
  5. Provides a static configure(String propertiesPath) method that loads everything in one call.

Then write a small Main class that uses your utility to set up logging, creates a few loggers in different packages, and demonstrates that the configuration works correctly.

Solution

logging-custom.properties
# Console configuration
console.level=WARNING

# File configuration
file.level=INFO
file.pattern=app.log
file.limit=50000
file.count=3
file.append=true

# Per-package levels
level.com.example.service=FINE
level.com.example.web=INFO
LoggingConfig.java
import java.io.FileInputStream;
import java.io.IOException;
import java.util.logging.*;

public class LoggingConfig {

    /**
     * Configures logging from a properties file.
     *
     * Supported properties:
     *   console.level       - Level for console output (default: INFO)
     *   file.level          - Level for file output (default: ALL)
     *   file.pattern        - File pattern for FileHandler (default: app.log)
     *   file.limit          - Max bytes per file (default: 50000)
     *   file.count          - Number of rotating files (default: 3)
     *   file.append         - Append to existing files (default: true)
     *   level.<package>    - Level for a specific package
     */
    public static void configure(String propertiesPath) throws IOException {
        java.util.Properties props = new java.util.Properties();
        try (FileInputStream in = new FileInputStream(propertiesPath)) {
            props.load(in);
        }

        Logger root = Logger.getLogger("");

        // Remove existing handlers
        for (var handler : root.getHandlers()) {
            root.removeHandler(handler);
        }

        // Set root level to ALL — let handlers do the filtering
        root.setLevel(Level.ALL);

        // Console handler
        Level consoleLevel = parseLevel(props.getProperty("console.level", "INFO"));
        ConsoleHandler consoleHandler = new ConsoleHandler();
        consoleHandler.setLevel(consoleLevel);
        consoleHandler.setFormatter(new SimpleFormatter());
        root.addHandler(consoleHandler);

        // File handler
        Level fileLevel = parseLevel(props.getProperty("file.level", "ALL"));
        String pattern = props.getProperty("file.pattern", "app.log");
        int limit = Integer.parseInt(props.getProperty("file.limit", "50000"));
        int count = Integer.parseInt(props.getProperty("file.count", "3"));
        boolean append = Boolean.parseBoolean(props.getProperty("file.append", "true"));

        FileHandler fileHandler = new FileHandler(pattern, limit, count, append);
        fileHandler.setLevel(fileLevel);
        fileHandler.setFormatter(new SimpleFormatter());
        root.addHandler(fileHandler);

        // Per-package level overrides
        for (String key : props.stringPropertyNames()) {
            if (key.startsWith("level.")) {
                String packagePrefix = key.substring("level.".length());
                Level pkgLevel = parseLevel(props.getProperty(key));
                Logger.getLogger(packagePrefix).setLevel(pkgLevel);
            }
        }

        System.out.println("Logging configured from: " + propertiesPath);
        System.out.println("  Console level: " + consoleLevel);
        System.out.println("  File level:    " + fileLevel);
    }

    private static Level parseLevel(String name) {
        try {
            return Level.parse(name);
        } catch (IllegalArgumentException e) {
            System.out.println("Unknown level '" + name + "', defaulting to INFO.");
            return Level.INFO;
        }
    }
}
LoggingDemo.java
import java.util.logging.Level;
import java.util.logging.Logger;

public class LoggingDemo {

    private static final Logger serviceLogger =
            Logger.getLogger("com.example.service");
    private static final Logger webLogger =
            Logger.getLogger("com.example.web");
    private static final Logger otherLogger =
            Logger.getLogger("com.example.other");

    public static void main(String[] args) throws Exception {
        // Configure logging from the properties file
        LoggingConfig.configure("logging-custom.properties");

        // service logger: FINE level configured in properties
        serviceLogger.info("[SERVICE] Loading user profile.");
        serviceLogger.fine("[SERVICE] Querying database for user ID: 42.");
        serviceLogger.warning("[SERVICE] User profile cache miss.");

        // web logger: INFO level configured in properties
        webLogger.info("[WEB] GET /api/users/42 — 200 OK");
        webLogger.fine("[WEB] This FINE message is filtered out (web level is INFO).");
        webLogger.warning("[WEB] Request took 3200ms (slow).");

        // other logger: no specific level, inherits root (ALL)
        otherLogger.info("[OTHER] Scheduled task started.");
        otherLogger.fine("[OTHER] Processing batch of 100 items.");
        otherLogger.severe("[OTHER] Batch processing failed: out of memory.");
    }
}
Console Output (WARNING and above only, per configuration)
Logging configured from: logging-custom.properties Console level: WARNING File level: ALL Jul 15, 2025 11:30:00 AM LoggingDemo main WARNING: [SERVICE] User profile cache miss. Jul 15, 2025 11:30:00 AM LoggingDemo main WARNING: [WEB] Request took 3200ms (slow). Jul 15, 2025 11:30:00 AM LoggingDemo main SEVERE: [OTHER] Batch processing failed: out of memory.

The app.log.0 file will contain all messages at INFO and above, including the FINE message from the service package (because the service logger's level is set to FINE and the file handler's level is ALL ), but not the FINE message from the web package (because the web logger's level is INFO ).

Ad placeholder
Advertisement

Next Steps

Now that you understand Java's built-in logging framework, here are recommended next topics:

  • Exception Handling — Learn how to properly handle errors and combine exception handling with logging.
  • File Handling — Deeper understanding of Java I/O, which complements file-based logging.
  • Classes and Objects — If you haven't covered OOP yet, understanding classes is essential for organizing loggers in real applications.