Command Palette

Search for a command to run...

PHASE 7Intermediate Java 14+ ~36 min· topic 8 of 8

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 was null.
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 null makes 'not found' visible in the method's type.
Control flow
The order in which statements run, decided by if, loops, return and 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.

Main.javawhole filejava
// 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.

Where to catch: at the level that can decidediagram
Rendering diagram…

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.

terminal
$ java Main.java
── expected output ──
Exception in thread "main" java.lang.NullPointerException: Cannot read field "city" because "Main.find(String).address" is null
at Main.main(Main.java:14)

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.

terminal
$ javac Main.java && java Main
javac -g Main.java && java Main
── expected output ──
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.length()" because "<local1>" is null
at Main.main(Main.java:4)
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.length()" because "name" is null
at Main.main(Main.java:4)

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.

Main.javawhole filejava
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 null

06Exceptions 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).

Main.javawhole filejava
// 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.

Main.javawhole filejava
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. 1

    Read a chain of nulls

    In the helpful NPE example, set c.address = new Address(); before the show calls. Predict the new message for line 2 (hint: now city is the null one, and the failing action is a method call). Then run.

  2. 2

    Find the hidden bug

    In the swallowing example, add "1,000" to the list. Predict bad total and the rejected line. Which version would you rather discover the problem with?

  3. 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 becomes null).

Code & diagrams

Helpful NullPointerException messages Java 14+ New tab

Only fields, static fields and method results are used here: they are always named. Local variables need javac -g.

Sign in to run this example in your browser.

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 null
Swallowing vs reporting with context New tab

The bad version also silently dropped ' 80' (a leading space), so its total is wrong in a way nobody would notice.

Sign in to run this example in your browser.

Expected output

bad total: 165
rejected: 2 bad prices: [line 3: '9O' is not a price, line 5: '' is not a price]
good total: 245
Expected absence in the type, real failures as exceptions Java 9+ New tab
Sign in to run this example in your browser.

Expected output

samosa in stock? false
tea stock: 12
reserved 2 tea
cannot reserve: only 0 cake left, asked 1
cannot reserve: unknown item: samosa
A top-level handler: log once (fragment)java

Lower 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.

terminal
$ java Main.java
── what you'll see ──
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.length()" because "<local1>" is null
at Main.main(Main.java:4)

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.

terminal
$ java Main.java
── what you'll see ──
charged: 40

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

01

Why not just return null when the database is down?

02

Should OrderStorageException be checked or unchecked?

03

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 first getMessage() call and not for NPEs created explicitly with new 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 as Throwable.fillInStackTrace and StackTraceElement allocation. Keep exceptions exceptional on hot paths.

  • ▸

    Logging frameworks (SLF4J/Logback, Log4j 2) print Caused by: and Suppressed: sections when the exception is passed as the last argument. Passing only e.getMessage() or e.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, return in finally, ignored InterruptedException, and dereferences that may be null. Annotations like JSpecify's @Nullable let tools (and IDEs) check null-safety across APIs.

Remember this

  1. 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 variable ignored, and write a comment explaining why it's safe. Otherwise handle it, rethrow it, or wrap it with its cause.

  2. 2

    Catch specific types, at the right level. catch (Exception e) also catches NullPointerException, ClassCastException and 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. 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. 4

    Don't use exceptions for ordinary control flow. Creating an exception captures a stack trace, and code that uses try/catch as an if is slow and hard to read. Expected outcomes like 'not found' belong in the return type, for example Optional<Customer> (Phase 10). Check str.isEmpty() before parsing rather than catching every failure, when emptiness is a normal case.

  5. 5

    Helpful NullPointerExceptions (JEP 358, Java 14, switched on by default in Java 15) make the JVM describe the failed action and the null expression: 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 when getMessage() is called, so it costs nothing on the normal path.

  6. 6

    Better still, prevent NullPointerException: validate at boundaries with Objects.requireNonNull(x, "x"), return empty collections and Optional instead of null, prefer "literal".equals(value) or Objects.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 catching InterruptedException, document with @throws), this is what reviewers look for.

Explain it without notes

01

Why is swallowing exceptions dangerous, and what should a catch block do instead?

02

What are helpful NullPointerException messages, and when do they show <local1>?

03

Where in an application should exceptions be caught and logged?

04

When should a method return Optional instead of throwing an exception?

05

List the exception-handling rules you would check in a code review.

Practice

01

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".

02

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.

03

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 Optional for expected absence and exceptions for real failures.

  • Done when you restore the interrupt flag whenever you catch InterruptedException without rethrowing.

  • Done when you can review a method and list its exception-handling problems.