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
printlnstatement runs every time, cluttering your console and slowing your program down. -
You can't route output to different destinations.
printlnalways 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:
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.
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.");
}
}
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:
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);
}
}
}
Several things to notice in the output above:
-
The
config,fine,finer, andfinestmessages did not appear because the default level isINFO. -
When you pass a
Throwableas the third argument tolog(), 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:
// 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:
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});
}
}
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:
-
comis the parent ofcom.example -
com.exampleis the parent ofcom.example.service -
com.example.serviceis the parent ofcom.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.
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 toSystem.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.
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'.");
}
}
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.
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");
}
}
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"— createsapp.log.0,app.log.1, etc. in the working directory -
"logs/app.log"— creates files in alogs/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.
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();
}
}
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 forFileHandler(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:
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:
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.");
}
}
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:
# ============================================
# 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
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.
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);
}
}
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.");
}
}
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 — 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.
// 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:
// 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:
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:
-
INFOfor business-significant events (order received, payment approved, inventory released) -
FINEfor technical details (checking inventory, initiating payment) — only visible when debugging -
WARNINGfor a recoverable business problem (out of stock) -
SEVEREfor 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)
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.loggingis 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: Build a Configurable Logger Utility
Create a
LoggingConfig
class that:
- Reads a logging configuration from a properties file.
-
Sets up a
ConsoleHandlerwithSimpleFormatterand the level specified in the propertyconsole.level(default:INFO). -
Sets up a
FileHandlerwithSimpleFormatterand the level specified infile.level(default:ALL). The file pattern, size limit, and file count should also come from properties. -
Allows setting per-package levels via properties like
level.com.example.service=FINE. -
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
# 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
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;
}
}
}
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.");
}
}
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
).
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.