Topic 7.8
Exception Best Practices and Helpful NullPointerExceptions
In one line
Good exception handling means failing fast with clear messages, catching only what you can handle, never swallowing errors, keeping causes, closing resources and logging once. Since Java 14, NullPointerException messages also say exactly which value was null.
Think of it like this
A good car dashboard. When something is wrong, a specific light comes on right away ("oil pressure low"), not a vague "error" days later when the engine seizes. Nobody tapes over a warning light to make the dashboard look clean. And the mechanic gets the full history, not just the last symptom. Good exception handling is the same: specific, immediate, never hidden, and with the full story kept.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Swallowing
- Catching an exception and doing nothing with it, so the failure silently disappears.
- Helpful NPE message
- The detailed text Java 14+ puts in a
NullPointerException, naming the action that failed and what wasnull. - JEP
- JDK Enhancement Proposal: the document that describes a new Java feature. Helpful NPEs are JEP 358.
- Debug info (`-g`)
- Extra data javac can store in class files, such as local variable names. Without it, helpful NPE messages show
<local1>instead of a name. - Top-level handler
- One place near the start of a request, job or thread that catches anything not handled lower down, logs it once and reports a failure.
- Context
- The extra facts that make an error message useful: which order, which file, which value.
- `Optional`
- A container that holds either one value or nothing (Java 8). Returning it instead of
nullmakes 'not found' visible in the method's type. - Control flow
- The order in which statements run, decided by
if, loops,returnand so on. Exceptions shouldn't be used as a replacement for these.
Step by step
01The worst catch block
catch (Exception e) {} is the most damaging line in many codebases. The program keeps running with wrong data, and there's no trace anywhere to tell you why.
Code review rule: every catch block must do at least one of these: handle the situation (fallback, retry, default that is genuinely correct), rethrow, or wrap with the cause. 'Log and continue' is acceptable only when continuing is genuinely correct.
// BAD: if the file is corrupt, the app silently starts with no settings
try {
settings = loadSettings(path);
} catch (Exception e) { }
// GOOD: a missing file is expected; anything else is a real error
try {
settings = loadSettings(path);
} catch (NoSuchFileException e) {
settings = Settings.defaults(); // genuinely correct fallback
} catch (IOException e) {
throw new UncheckedIOException("cannot read settings from " + path, e);
}02Where to catch: at the level that can decide
Low-level code (a parser, a repository) usually can't decide what a failure means for the user, so it throws or wraps. The layer that knows the business (a service) may handle specific cases. A top-level handler (a web framework's exception handler, a job runner's main loop) catches everything else, logs it once with the full trace, and returns an error response or marks the job as failed.
03Helpful NullPointerException messages
Before Java 14, an NPE on order.customer.address.city.length() just said java.lang.NullPointerException and a line number. Which of four references was null? You had to guess or add logging.
Since Java 14 (default since 15), the JVM analyses the bytecode of the failing instruction and describes it. You'll see the action that failed (Cannot invoke, Cannot read field, Cannot load from int array, Cannot read the array length, Cannot assign field, Cannot throw exception, Cannot enter synchronized block) and what was null.
04Why some messages say <local1>
Local variable names aren't stored in class files unless you compile with -g (or -g:vars). Without them, the JVM can only say "<local1>" (slot 1 of the frame) or "<parameter1>". Fields, static fields and method calls are always named because their names are part of the bytecode.
Maven and Gradle compile with debug info by default, so production NPEs usually show real names. Plain javac and java Main.java don't. The feature can be turned off with -XX:-ShowCodeDetailsInExceptionMessages; the message is then null, as before Java 14.
05Prevent the NPE in the first place
A helpful message tells you where, but the bug is already in production. Make null impossible or explicit instead: reject it at the boundary with a clear message, never return null collections, and use Optional for 'maybe absent' results.
Customer(String name, List<String> tags) {
this.name = Objects.requireNonNull(name, "name"); // fails here, with the field named
this.tags = List.copyOf(tags); // also rejects null elements
}
List<Order> ordersFor(String id) {
return orders.getOrDefault(id, List.of()); // never return null for a list
}
Optional<Customer> find(String id) { // absence is in the type
return Optional.ofNullable(byId.get(id));
}
if ("admin".equals(role)) { ... } // safe even if role is null06Exceptions are not an if statement
Using exceptions to detect expected situations is slow (stack trace capture) and hides intent. Ask first when the question is cheap; catch when the failure is genuinely unexpected or can't be checked in advance (a file can disappear between exists() and open(), so there you must catch).
// BAD: exception as a loop terminator
try {
int i = 0;
while (true) total += values[i++];
} catch (ArrayIndexOutOfBoundsException e) { }
// GOOD
for (int v : values) total += v;07InterruptedException: don't lose the signal
InterruptedException means another thread asked this one to stop. Catching it clears the thread's interrupt flag. If you swallow it, the request to stop is lost and the thread may run forever.
Either let it propagate (declare throws InterruptedException), or restore the flag before doing anything else: Thread.currentThread().interrupt();. The concurrency phase builds on this.
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // keep the stop request visible
return; // and stop what we were doing
}08The checklist reviewers use
Specific catch types. No empty catches. Cause kept when wrapping. Messages with context and without secrets. Resources in try-with-resources. No return or throw in finally. Interrupts restored. Exceptions documented with @throws. Logged once, at the top. Standard exceptions for argument and state errors, custom ones only when they carry meaning.
Try it yourself
- 1
Read a chain of nulls
In the helpful NPE example, set
c.address = new Address();before theshowcalls. Predict the new message for line 2 (hint: nowcityis the null one, and the failing action is a method call). Then run. - 2
Find the hidden bug
In the swallowing example, add
"1,000"to the list. Predictbad totaland therejectedline. Which version would you rather discover the problem with? - 3
Turn the feature off
If you have a JDK locally, compile the NPE example and run it with
java -XX:-ShowCodeDetailsInExceptionMessages Main. Predict what each line prints now (hint: the message becomesnull).
Code & diagrams
Only fields, static fields and method results are used here: they are always named. Local variables need javac -g.
Expected output
1 -> Cannot read field "name" because "Main.current" is null
2 -> Cannot read field "city" because "Main.find(String).address" is null
3 -> Cannot read field "name" because the return value of "Main.find(String)" is null
4 -> Cannot load from int array because "Main.scores" is null
5 -> Cannot read the array length because "Main.scores" is null
6 -> Cannot assign field "name" because "Main.current" is nullThe bad version also silently dropped ' 80' (a leading space), so its total is wrong in a way nobody would notice.
Expected output
bad total: 165
rejected: 2 bad prices: [line 3: '9O' is not a price, line 5: '' is not a price]
good total: 245Expected output
samosa in stock? false
tea stock: 12
reserved 2 tea
cannot reserve: only 0 cake left, asked 1
cannot reserve: unknown item: samosaLower layers throw or wrap with context; they don't log. Passing e as the last logger argument prints the trace.
// One place catches what nothing else handled: a job runner's loop
for (Job job : jobs) {
try {
job.run();
job.markDone();
} catch (RetryableException e) {
job.scheduleRetry(); // a case we can decide here
} catch (RuntimeException e) {
log.error("job {} failed", job.id(), e); // logged ONCE, with the full trace and causes
job.markFailed(e.getMessage()); // the loop carries on with the next job
}
}Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
A local variable that is null
Write String name = args.length > 0 ? args[0] : null; System.out.println(name.length()); and run with java Main.java and no arguments.
Break #2
Swallow an exception in a loop
In the swallowing example, call totalBad(List.of("10", "ten", "30")) and use the result as the amount to charge a customer.
Myth vs fact
Myth
Catching Exception everywhere makes code robust.
Fact
It makes code quiet, not robust. It hides bugs like NullPointerException together with the errors you meant to handle. Robust code catches specific failures it can handle and lets the rest reach a top-level handler.
Myth
Helpful NPE messages slow down every null check.
Fact
The message is computed lazily from the bytecode only when getMessage() is called. Code that never throws, or throws and never reads the message, pays nothing extra.
Myth
Logging the exception before rethrowing is the safe thing to do.
Fact
If every layer does it, one failure appears five times in the logs, often with different partial context. Log once, where the exception is finally handled, and add context by wrapping instead.
Myth
Returning null is cheaper and simpler than throwing or using Optional.
Fact
It moves the failure to some later line, far from its cause, as a NullPointerException. For 'not found', return Optional or an empty collection; for a broken rule, throw.
Interview problem
The problem
Review this code
An interviewer shows a method and asks you to list every exception-handling problem and rewrite it: public Order load(String id) { try { Connection c = pool.get(); ResultSet rs = c.createStatement().executeQuery("SELECT * FROM orders WHERE id='" + id + "'"); rs.next(); return map(rs); } catch (Exception e) { e.printStackTrace(); return null; } finally { return cache.get(id); } }
You're given
- Find at least five problems.
- Rewrite the method so resources are always closed and callers can tell 'not found' from 'failed'.
The interviewer follows up
Why not just return null when the database is down?
Should OrderStorageException be checked or unchecked?
Where should this failure be logged?
When it breaks
A swallowed exception silently drops data in a nightly import
What you see
The import reports success every night, but a catch (Exception e) {} around each row hides parse errors after an upstream format change. Weeks later, finance notices missing transactions. There's no log line to show when it started.
Fix & prevent
Catch only the expected parse exception, record each bad row with its line number and value, fail the job (or alert) when the error count is above a threshold, and expose a metric for rejected rows.
Connections leak because close() is skipped on an exception path
What you see
Under normal traffic everything works. When a downstream service starts timing out, every failed request leaves a database connection open. The pool runs dry, and then even healthy requests fail with 'connection is not available, request timed out'.
Fix & prevent
Open every Connection, Statement and ResultSet in try-with-resources. Enable the pool's leak detection (for example HikariCP's leakDetectionThreshold) to find the remaining leaks from their stack traces.
NullPointerExceptions in the logs suddenly have no message and no stack trace
What you see
After running for a while, logs show bare java.lang.NullPointerException lines. The JIT has switched a hot throwing site to a preallocated exception (OmitStackTraceInFastThrow), so there's nothing to debug from.
Fix & prevent
Search back in the logs for the first occurrences, which still have full traces, or restart with -XX:-OmitStackTraceInFastThrow. Then fix the null at its source.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
JEP 358's message is built by
NullPointerException.getExtendedNPEMessage(), a native method that analyses the bytecode around the failing instruction (data-flow analysis back to the source of the null). It's computed on firstgetMessage()call and not for NPEs created explicitly withnew NullPointerException(...), or when the JIT has replaced the exception with a preallocated one (OmitStackTraceInFastThrow). - ▸
Exception cost is dominated by
fillInStackTrace, proportional to stack depth. In deep framework stacks (100+ frames) one exception can cost tens of microseconds. Under load, exception-heavy code paths show up in profilers asThrowable.fillInStackTraceandStackTraceElementallocation. Keep exceptions exceptional on hot paths. - ▸
Logging frameworks (SLF4J/Logback, Log4j 2) print
Caused by:andSuppressed:sections when the exception is passed as the last argument. Passing onlye.getMessage()ore.toString()loses the trace. Structured logging should include the exception class and root cause class as separate fields for alerting. - ▸
Static analysis catches many of these mistakes before review: Error Prone, SpotBugs and Sonar flag empty catches,
returninfinally, ignoredInterruptedException, and dereferences that may be null. Annotations like JSpecify's@Nullablelet tools (and IDEs) check null-safety across APIs.
Remember this
- 1
Never swallow exceptions. An empty
catch (Exception e) { }turns a loud, debuggable failure into silently wrong results: missing rows, zero totals, orders that never ship. If you truly mean to ignore one, catch the narrowest type, name the variableignored, and write a comment explaining why it's safe. Otherwise handle it, rethrow it, or wrap it with its cause. - 2
Catch specific types, at the right level.
catch (Exception e)also catchesNullPointerException,ClassCastExceptionand every other bug. Catch the types you expect and can handle. Handle an exception where there's enough context to make a decision, often far from where it was thrown, and let everything else propagate to a top-level handler that logs it once and fails the request or the job. - 3
Write messages for the person debugging at 3 a.m. Include the operation and the values involved:
"cannot reserve 3 x tea: only 1 left (order A-17)", not"error". Keep the cause when wrapping (Topic 7.5). Don't put passwords, tokens or full card numbers in messages: exceptions end up in logs. - 4
Don't use exceptions for ordinary control flow. Creating an exception captures a stack trace, and code that uses
try/catchas anifis slow and hard to read. Expected outcomes like 'not found' belong in the return type, for exampleOptional<Customer>(Phase 10). Checkstr.isEmpty()before parsing rather than catching every failure, when emptiness is a normal case. - 5
Helpful NullPointerExceptions (JEP 358, Java 14, switched on by default in Java 15) make the JVM describe the failed action and the
nullexpression:Cannot invoke "String.length()" because the return value of "Customer.getName()" is null. Fields, array elements, static fields and method return values are named exactly. Local variables and parameters are named only if the class was compiled with-g(debug info); otherwise you see"<local1>". The message is computed lazily from the bytecode, only whengetMessage()is called, so it costs nothing on the normal path. - 6
Better still, prevent
NullPointerException: validate at boundaries withObjects.requireNonNull(x, "x"), return empty collections andOptionalinstead ofnull, prefer"literal".equals(value)orObjects.equals(a, b), and keep fields non-null by initialising them in constructors. Combined with the other habits here (close with try-with-resources, restore the interrupt flag after catchingInterruptedException, document with@throws), this is what reviewers look for.
Explain it without notes
Why is swallowing exceptions dangerous, and what should a catch block do instead?
What are helpful NullPointerException messages, and when do they show <local1>?
Where in an application should exceptions be caught and logged?
When should a method return Optional instead of throwing an exception?
List the exception-handling rules you would check in a code review.
Practice
Rewrite static int port(String s) { try { return Integer.parseInt(s); } catch (Exception e) { return 0; } } so that a blank string means the default 8080, anything unparsable throws IllegalArgumentException with the value and the cause, and ports outside 1..65535 are rejected. Test with "", "9000", "90x" and "70000".
Write a tiny top-level loop over three tasks (Runnables): one succeeds, one throws IllegalStateException("disk full") wrapped as the cause of RuntimeException("task 2 failed"), and one succeeds. Print done/FAILED per task with the root cause message, and make sure the loop continues.
Write static Optional<String> cityOf(Map<String, String> addresses, String user) and use it to print city: Pune for a known user and city: unknown for an unknown one, with no null checks in main.
Trade-offs
- ↔
Failing fast stops bad data early but makes systems less forgiving; for batch work, 'skip and report' (collect errors, continue, fail at the end above a threshold) can be the better balance, as long as the skips are visible.
- ↔
Detailed messages speed up debugging but can leak sensitive data into logs and error responses; include identifiers and sizes, not secrets, and show users a generic message while logging the details.
- ↔
Turning off stack-trace omission (
-XX:-OmitStackTraceInFastThrow) keeps every trace but costs CPU when a hot path throws repeatedly; many teams keep the default and rely on the first occurrences, others disable it to never lose a trace.
Done when you can
Done when you never write an empty catch block, and can explain the damage one causes.
Done when you can read any helpful NPE message and name the null expression, and know when you'll see
<local1>.Done when you can decide where to catch, where to wrap and where to log, so each failure is logged once.
Done when you choose
Optionalfor expected absence and exceptions for real failures.Done when you restore the interrupt flag whenever you catch
InterruptedExceptionwithout rethrowing.Done when you can review a method and list its exception-handling problems.