Logging
Good logging turns a production incident from guesswork into a quick read of what actually happened — but only if messages are structured, leveled correctly, and never leak secrets.
Logging is how a running program records what it's doing for humans (and tools) to read later, without stopping to ask a debugger for it interactively. Unlike System.out.println, a real logging framework gives you severity levels, configurable output destinations, structured/parameterized messages, and the ability to turn verbosity up or down without redeploying code — all of which matter enormously once code is running somewhere you can't attach a debugger to.
Why Not Just System.out.println
System.out.println writes unconditionally to standard output with no severity, no timestamp, no source location, and no way to disable it short of removing the line and recompiling. A real logging framework (java.util.logging, Log4j 2, or the SLF4J facade in front of one of them) adds all of that: each log call carries a severity level, output can be routed to files/consoles/remote log aggregators via configuration alone, and verbosity can be adjusted per-package at runtime without touching code.
This does not mean println is never appropriate — for a small script, a one-off utility, or a command-line tool's actual output (not diagnostic information), plain console output is fine. Logging frameworks earn their complexity for long-running services and anything that needs to be operated and debugged in production.
Logging Levels
Common logging levels, from least to most severe, express how important a message is: TRACE/FINEST (extremely detailed, rarely enabled), DEBUG/FINE (developer-oriented diagnostic detail), INFO (normal operational events worth recording — "server started," "job completed"), WARN/WARNING (something unexpected but not immediately breaking anything), ERROR/SEVERE (a failure that needs attention). The exact level names differ slightly between frameworks (java.util.logging uses FINEST/FINE/INFO/WARNING/SEVERE; Log4j 2/SLF4J use TRACE/DEBUG/INFO/WARN/ERROR), but the concept — a severity ladder you can filter by — is the same.
In production, the log level might be configured to INFO and above, so the debug() call is silently skipped -- with no code change -- while info() and error() still record what happened. Turning the level down to DEBUG (via configuration, not code) during an investigation reveals the extra detail without a redeploy.
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class LevelExample {
private static final Logger log = LoggerFactory.getLogger(LevelExample.class);
static void handleRequest(String requestId) {
log.debug("Handling request {}", requestId);
try {
// ... business logic ...
log.info("Request {} completed successfully", requestId);
} catch (Exception e) {
log.error("Request {} failed", requestId, e);
}
}
}Choosing the Right Level
A common mistake is logging everything at INFO (drowning real operational signal in noise) or logging genuine failures at DEBUG (where they're invisible in a production configuration that only shows INFO and above). A reasonable convention: DEBUG for detail useful only while actively developing or investigating; INFO for events an operator watching the system would care about; WARN for something recoverable but worth someone's attention; ERROR for a failure that likely needs a human response.
Structured and Parameterized Messages
Modern logging APIs (SLF4J, Log4j 2) support parameterized messages using {} placeholders instead of string concatenation, e.g. log.info("User {} logged in from {}", userId, ipAddress). This is not just a style preference: the message is only actually constructed (the placeholders substituted) if that log statement's level is enabled — string concatenation in a disabled println-style call always happens regardless, wasting work for output that gets thrown away.
"Structured logging" (in a stricter sense, common in server-side systems) goes further: emitting log entries as key-value pairs or JSON objects rather than free-form text, so log-aggregation tools can query, filter, and alert on specific fields (userId, requestId, latencyMs) directly, rather than parsing prose.
// Wasteful even when DEBUG is disabled: the concatenation always runs
log.debug("Processing item " + item.getId() + " with status " + item.getStatus());
// Efficient: string building is skipped entirely if DEBUG is disabled
log.debug("Processing item {} with status {}", item.getId(), item.getStatus());Logging Exception Stack Traces
When logging an exception, pass the Throwable itself as an argument (most logging APIs accept it as a final argument after the message and placeholders) rather than converting it to a string yourself — the logging framework then prints the full stack trace (including any Caused by: chain) in a properly formatted way, and log-analysis tooling generally knows how to recognize and handle that structure.
try {
riskyOperation();
} catch (Exception e) {
log.error("riskyOperation failed for id={}", id, e); // e as the final argument
// NOT: log.error("riskyOperation failed: " + e.getMessage()); -- loses the stack trace entirely
}Avoid Logging Sensitive Data
Log files are frequently stored, retained for extended periods, shipped to third-party log-aggregation services, and readable by a broader set of people (support engineers, on-call responders) than the code that generated them. Passwords, API keys/tokens, full credit card numbers, and other sensitive personal data should never appear in a log message — logging a request or exception object wholesale (via a default toString()) is a common accidental way this happens.
Some organizations apply automated log-scrubbing/redaction as a safety net, but that should be treated as defense-in-depth, not a substitute for deliberately choosing what to log in the first place.
// DANGEROUS: logs the full request object, which may include a password field
log.info("Login attempt: {}", loginRequest);
// SAFER: log only the fields that are actually needed and safe to retain
log.info("Login attempt for username={}", loginRequest.getUsername());Logging Versus System.out
The practical distinctions: System.out/System.err always print (no severity filtering), always go to whatever stream the process's standard output/error is currently attached to, provide no source-class/line context by default, and offer no runtime-configurable verbosity. A logging framework gives you all of that as first-class features, generally without any meaningful runtime cost when a level is disabled (a disabled log call is typically a very cheap no-op check, not a full message construction).
System.out.println Logging framework (SLF4J/Log4j/j.u.l.)
Severity filtering No Yes (per level, per package/class)
Output routing Fixed to stdout Configurable: file, console, network, rotate
Configurable at runtime No Yes, without redeploying code
Parameterized/lazy No -- always concatenates Yes -- skips work if level is disabled
Structured context No Often yes (MDC, JSON encoders, key-value fields)The Facade Pattern: SLF4J
SLF4J (Simple Logging Facade for Java) is a thin, widely-used abstraction layer: application and library code depends only on the SLF4J API, while the actual logging implementation (Logback, Log4j 2, or java.util.logging via a bridge) is plugged in at deployment time via which binding jar is on the classpath. This lets libraries log without forcing every consumer of that library to adopt one specific logging implementation.
Practical Usage: What to Log
Good candidates for logging: application startup/shutdown and configuration loaded, entry/exit of significant operations at INFO or DEBUG, all caught exceptions that aren't purely expected control flow, external calls (with latency, for later performance analysis), and any decision point that would help someone reconstruct "what happened" after the fact without needing to reproduce the bug live. Avoid logging inside extremely hot, tight loops at anything above DEBUG/TRACE — even a cheap disabled-check has some overhead when called millions of times.
Edge Cases
Logging inside a catch block and then also rethrowing the exception can cause the same failure to be logged multiple times as it propagates up through several layers, each with its own catch-and-log — a noisy anti-pattern; generally, log an exception once, at the point where it's finally handled (or decided to be fatal), not at every layer it passes through.
Logging frameworks are themselves I/O — writing to disk or over a network — so extremely high-volume logging can become a real performance bottleneck or even a backpressure/blocking risk if the logging destination is slow; asynchronous appenders (buffering log writes off the calling thread) are a common mitigation in high-throughput systems.
Mental Model
Think of logging as leaving a trail of labeled breadcrumbs as your program runs, each stamped with how important it is. When something goes wrong later, you don't need to have been watching live — you follow the trail, filtering to just the important breadcrumbs (WARN and above) for a quick overview, or turning on every breadcrumb (DEBUG/TRACE) for a specific area when you need to reconstruct exactly what happened, all without having to catch the program in the act.
Quick Reference
Levels (SLF4J/Log4j2): TRACE < DEBUG < INFO < WARN < ERROR
Parameterized: log.info("User {} did {}", userId, action) -- lazy, skipped if level disabled
Exception: log.error("failed for {}", id, exception) -- pass Throwable as final arg
Never log: passwords, tokens, full card numbers, raw request/response bodies with secrets
Facade: code depends on SLF4J API; Logback/Log4j2/j.u.l. plugged in at deploy time
Vs println: leveled, routable, runtime-configurable, no redeploy needed to change verbosityKey Takeaways
Use a real logging framework (SLF4J + Logback/Log4j2, or java.util.logging) instead of System.out for anything beyond a small script -- it adds severity levels, configurable routing, and runtime-adjustable verbosity with no redeploy.
Choose the log level deliberately: DEBUG for development detail, INFO for operator-relevant events, WARN for recoverable oddities, ERROR for failures needing attention.
Use parameterized {} messages, not string concatenation -- concatenation always runs even when the level is disabled, while parameterized messages skip that work entirely.
Pass the exception object itself to the logging call so the framework prints the full stack trace (including causes) properly, rather than converting it to a string yourself.
Never log secrets, tokens, full card numbers, or wholesale request/response objects that may contain sensitive fields -- log files are retained and read more broadly than most people assume.
Log an exception once, at the point it's actually handled or deemed fatal, rather than at every layer it passes through, to avoid noisy duplicate log entries.
System.out.println("Processing order " + orderId);
log.info("Processing order {}", orderId);
println has no severity, can't be filtered or routed, and requires a code change (and redeploy) to turn off; a logging call with a level can be silenced or redirected purely through configuration.
log.debug("loaded config: " + config.toString());
log.debug("loaded config: {}", config);
String concatenation runs unconditionally even if DEBUG is disabled, wasting the toString() work; the parameterized form only builds the message if DEBUG is actually enabled.
log.error("operation failed: " + e.getMessage());
log.error("operation failed", e);
Converting the exception to just its message string discards the entire stack trace and any chained causes; passing the Throwable directly lets the logging framework print the full trace properly.
log.info("login request received: {}", loginRequestObject); // may include a password field log.info("login request received for username={}", loginRequestObject.getUsername());
Logging an object wholesale via its default toString() can accidentally include sensitive fields like a password; log only the specific, safe fields that are actually needed.
Name three concrete capabilities a real logging framework gives you that System.out.println does not.
Severity-level filtering (so verbosity can be tuned without code changes), configurable output routing (console, file, network, log-aggregation service) purely through configuration, and lazy/parameterized message construction that avoids building a message string when its level is disabled.
Why is log.debug("value: {}", x) generally preferred over log.debug("value: " + x)?
The concatenated form builds the full message string every time the line executes, regardless of whether DEBUG is enabled; the parameterized form only substitutes the placeholder and builds the string if DEBUG is actually enabled, avoiding wasted work when it's disabled (the common case in production).
Why should you pass an exception object directly to a logging call rather than logging its getMessage() as a string?
Passing the Throwable itself lets the logging framework print the complete, properly formatted stack trace, including any chained 'Caused by:' exceptions, which is essential for diagnosing the actual failure; getMessage() alone discards the stack trace and cause chain entirely.
What is a common, accidental way sensitive data ends up in logs, and how do you avoid it?
Logging a whole object (a request, response, or entity) via its default toString(), which may include fields like passwords or tokens the developer wasn't thinking about at the log call site; avoid it by logging only the specific fields known to be safe and needed, not the object wholesale.
Explain the difference in intended purpose between INFO and DEBUG log levels.
INFO records normal operational events an operator watching the running system would care about (e.g. 'server started', 'job completed'); DEBUG records fine-grained diagnostic detail useful mainly to a developer actively investigating something, and is typically disabled in a normal production configuration.
What problem does the 'log once, at the point an exception is finally handled' guideline address, and why is logging at every catch block along the propagation path a problem?
It addresses log noise: if an exception is caught, logged, and rethrown at several layers as it propagates up the call stack, the same underlying failure appears multiple times in the logs, making it harder to tell how many distinct failures actually occurred and cluttering the log output.
What is SLF4J, and why do many Java libraries depend on it rather than a specific logging implementation directly?
SLF4J is a thin logging facade/API; library code logs against the SLF4J interface only, and the actual implementation (Logback, Log4j 2, java.util.logging via a bridge) is chosen by whatever application ultimately uses the library, at deployment time -- this avoids forcing every consumer of a library to adopt that library's specific logging framework choice.
Write a method processPayment(String orderId, double amount) that logs an INFO message on success and an ERROR message with the full exception on failure, using parameterized SLF4J-style logging.
import org.slf4j.Logger; import org.slf4j.LoggerFactory; public class PaymentProcessor { private static final Logger log = LoggerFactory.getLogger(PaymentProcessor.class); static void processPayment(String orderId, double amount) { try { chargeCard(orderId, amount); // pretend this can throw log.info("Payment processed for order {} amount={}", orderId, amount); } catch (Exception e) { log.error("Payment failed for order {} amount={}", orderId, amount, e); } } private static void chargeCard(String orderId, double amount) throws Exception { // charging logic } }
The success path logs at INFO with parameterized placeholders (cheap, and meaningful for operators watching the system); the failure path logs at ERROR, passing the caught exception as the final argument so the logging framework records the full stack trace, not just a message string -- both calls avoid string concatenation entirely.
What are the problems with this log statement, and how would you fix them?
Two problems: (1) it uses string concatenation instead of a parameterized {} placeholder, so the (potentially expensive) toString() call runs even when DEBUG is disabled; (2) it logs the entire User object, which includes passwordHash -- sensitive data that should never appear in logs even hashed, since hashes can still be attacked offline and shouldn't be needlessly exposed. Fix: 'log.debug("User details: id={} username={}", user.getId(), user.getUsername());' -- logging only the specific safe fields needed, in parameterized form.
Write code demonstrating the anti-pattern of logging an exception at every layer it propagates through, then show the corrected version that logs it only once.
// ANTI-PATTERN: logs the same failure three times as it propagates void layerA() { try { layerB(); } catch (Exception e) { log.error("layerA failed", e); throw new RuntimeException(e); } } void layerB() { try { layerC(); } catch (Exception e) { log.error("layerB failed", e); throw new RuntimeException(e); } } void layerC() { throw new IllegalStateException("root cause"); } // CORRECTED: propagate silently, log once at the top where it's finally handled void layerA_fixed() { try { layerB_fixed(); } catch (Exception e) { log.error("Request failed", e); /* handle/respond here */ } } void layerB_fixed() { layerC_fixed(); } // let it propagate, no log-and-rethrow void layerC_fixed() { throw new IllegalStateException("root cause"); }
In the anti-pattern version, the same IllegalStateException gets logged three separate times (once per layer) as it propagates, cluttering the logs and making it look like three distinct failures occurred. In the corrected version, only layerB and layerC let the exception propagate without catching it, and layerA -- the point where it's actually handled -- logs it exactly once, giving a clean, accurate log record of a single failure.
What is the practical problem with this logging call inside a tight, high-iteration loop, and how would you address it?
Logging at INFO (a level typically enabled in production) ten million times per run generates an enormous volume of low-value log output, consumes significant I/O and storage, and can become a real performance bottleneck since logging is itself I/O-bound work. Fix: either drop this to DEBUG/TRACE (disabled by default in production, so the cost becomes a cheap disabled-level check rather than full I/O), or better, log only periodically (e.g. every 100,000th element) or log a single summary after the loop completes rather than logging every single iteration.
log.debug("User details: " + user.toString());
// User.toString() includes: id, username, email, and passwordHashfor (int i = 0; i < 10_000_000; i++) {
log.info("Processing element {}", i);
process(i);
}