A System.out.println is a debugging scratchpad, not a logging system. Real logging gives you levels, timestamps, a configurable destination and a way to turn detail on in development and off in production. Learning the shape of that system is one of the highest-value habits you can pick up, because it changes how you diagnose a problem that only happens on a server.
Why do print statements fail in production?
Look at the output of a program that used prints for debugging.
public class PrintProblem {
static int parse(String text) {
System.out.println("entering parse with " + text);
if (text == null) {
System.out.println("text was null");
return 0;
}
int value = Integer.parseInt(text);
System.out.println("parsed value is " + value);
return value;
}
public static void main(String[] args) {
System.out.println("starting");
System.out.println("result " + parse("42"));
System.out.println("done");
}
}starting
entering parse with 42
parsed value is 42
result 42
doneThree problems jump out. There is no way to tell an important line from a noisy one, so the signal drowns. There is no timestamp, so you cannot line the output up with anything else that happened at that moment. And there is no level, so nothing can be filtered out later - every line is printed forever, or you delete the prints and lose the evidence.
There is a fourth, quieter problem: the prints change the code. You add them, the bug stops reproducing, and now the artefact you deployed is not the artefact you debugged.
What levels do you need?
| Level | Means | Typical use |
|---|---|---|
| ERROR | Something failed and someone may need to act | Failed request, unhandled exception |
| WARN | Suspicious but handled | Retrying, falling back, deprecated call |
| INFO | A normal, notable event | Request finished, job started |
| DEBUG | Detail for developers | Intermediate values, step-by-step trace |
| TRACE | Very fine detail | Loop-level tracing, usually off |
The point of levels is that a single configuration change can raise or lower the volume of output without touching the code. In development you run at DEBUG. In production you run at INFO, or WARN for a noisy service, and the extra detail is still there when you need it - you just have to turn it on.
What belongs in a log message?
A useful log line answers three questions: what happened, to which thing, and what was the context. That means identifiers - an order id, a user id, a request id - not a vague "here". It means the values that made the decision, not just the outcome. And it means a consistent structure, so that you can grep across a log file and find every occurrence of a particular id.
The bad version is "processing item". The good version is "applied 10% discount to order A-1, total 90.00". The second one is searchable and tells you what to investigate.
What does a minimal logging layer look like?
Production code normally logs through a facade such as SLF4J, which binds to an implementation at startup. The API is small, and you can model the same shape yourself to see what matters: a name for the component, a level check before the message is built, a timestamp, and the ability to attach an exception.
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
public class LoggingFacade {
private static final DateTimeFormatter STAMP = DateTimeFormatter.ofPattern("HH:mm:ss.SSS");
private final String name;
private final boolean debugEnabled;
LoggingFacade(String name, boolean debugEnabled) {
this.name = name;
this.debugEnabled = debugEnabled;
}
void info(String message) {
write("INFO", message, null);
}
void warn(String message) {
write("WARN", message, null);
}
void error(String message, Throwable cause) {
write("ERROR", message, cause);
}
void debug(String message) {
if (debugEnabled) {
write("DEBUG", message, null);
}
}
private void write(String level, String message, Throwable cause) {
String line = LocalDateTime.now().format(STAMP) + " [" + level + "] " + name + " - " + message;
System.out.println(line);
if (cause != null) {
System.out.println(" caused by: " + cause.getClass().getSimpleName() + ": " + cause.getMessage());
}
}
}22:38:56.067 [DEBUG] pricing - entering discount calculation
22:38:56.146 [INFO] pricing - discount applied to order A-1
22:38:56.148 [WARN] pricing - discount of 120 percent looks suspicious
22:38:56.148 [ERROR] pricing - could not apply discount
caused by: IllegalArgumentException: percentage must be between 0 and 100
22:38:56.164 [INFO] quiet - this line always appearsTwo design points in that class are worth copying. The component name goes on every line, so you can filter by it. And the level check happens before the message is assembled - building a message that nobody will read is pure waste, and this is where most of the cost of careless logging comes from.
Look at the last two lines of the output. The quiet logger drops the debug message entirely, and that is the behaviour you want: the level is decided by configuration, not by editing the source.
That last point is not a small optimisation, because a debug message is often the most expensive one you write.
public class GuardedMessage {
static final boolean DEBUG = false;
static String expensiveDetails() {
System.out.println("building details, which nobody asked for");
return "id=42, state=processing";
}
public static void main(String[] args) {
if (DEBUG) {
System.out.println("details : " + expensiveDetails());
}
System.out.println("always printed");
}
}always printedThe output shows what the guard buys. expensiveDetails was never called, because the level check ran first. Without that check the method would have run on every call and its output would have been thrown away - and if the details had come from a database call, you would have paid for a query whose result nobody read.
How do exceptions belong in logging?
Log the exception with its stack trace, not a message you typed about it. The stack trace says where the failure actually happened, and your message can only guess.
public class StackTraceDemo {
static void levelThree(String input) {
throw new IllegalArgumentException("bad input: " + input);
}
static void levelTwo(String input) {
levelThree(input);
}
static void levelOne(String input) {
levelTwo(input);
}
public static void main(String[] args) {
try {
levelOne("oops");
} catch (IllegalArgumentException e) {
System.out.println("message : " + e.getMessage());
StackTraceElement[] frames = e.getStackTrace();
System.out.println("frames : " + frames.length);
for (StackTraceElement frame : frames) {
System.out.println(" " + frame.getClassName() + "." + frame.getMethodName()
+ " line " + frame.getLineNumber());
}
System.out.println("cause : " + e.getCause());
}
}
}message : bad input: oops
frames : 4
StackTraceDemo.levelThree line 3
StackTraceDemo.levelTwo line 7
StackTraceDemo.levelOne line 11
StackTraceDemo.main line 16
cause : nullThree separate frames, each one further from the point of failure. When you look at a stack trace, read it from the top: the first frame is where it broke, and the ones below are the call path that got you there. A wrapped exception is worth reading from the bottom up instead, because the bottom is the original cause and the top is where your code finally noticed.
One more habit: log at the point where you can still do something about the problem. Catch an exception, add the context you know, log it with the cause, and either recover or rethrow. Catching it, logging a message that says "error occurred", and continuing as if nothing happened is the worst of both worlds.
What is a sensible debugging workflow?
- Reproduce the failure reliably before you start changing anything.
- Read the stack trace first - it names the line, not the symptom.
- Form a hypothesis, then test it with the smallest possible change.
- Use a real debugger with breakpoints before adding more prints.
- Write a failing test that reproduces the bug, then fix the code until it passes.
- Remove the temporary instrumentation once the test covers the case.
That last pair of steps is where tests earn their keep. A bug that has a failing test will never come back, and the test is cheaper than the print statements you would otherwise leave behind forever.
The habits to keep
- Use a logging facade with levels, never bare prints, in anything you intend to run twice.
- Include identifiers and the deciding values in every message.
- Guard expensive message construction behind a level check.
- Log exceptions with their stack trace.
- Keep the format consistent so logs can be grepped and parsed.
- Never log secrets, passwords or personal data.
Try it yourself: write a small class with a method that can fail in two different ways, and add a logging facade in front of it. Log the entry, the decision, a warning for a suspicious input and an error with the exception for the failure. Then run it at INFO level and again at DEBUG level, and write down what you would look for in a log file when a customer reports a wrong total.