☕ Java

try-catch

The try-catch statement is Java's primary mechanism for handling exceptions. Code that might throw an exception is placed in the try block; one or more catch blocks follow, each specifying the exception type to handle and providing the handling logic. When an exception is thrown inside the try block, execution of the try block immediately stops and the JVM searches the catch blocks in order for the first one whose type matches the thrown exception. Understanding the control flow precisely — what runs, what stops, and in what order — is essential for writing correct exception handling code. This entry covers try-catch control flow, exception type matching, catching by supertype, variable scope, and the key principles for writing good catch blocks.

try-catch Control Flow

The try block and catch block together define a guarded region. Execution enters the try block and proceeds normally until either all statements complete (no exception) or an exception is thrown. When an exception is thrown, the JVM immediately stops executing the try block at the exact point of the throw — no further statements in the try block run after the throw point. The JVM then evaluates each catch block in order, looking for the first one whose declared exception type is the same as or a supertype of the thrown exception. If a matching catch block is found, its body executes with the exception object bound to the catch variable. After the catch block finishes, execution continues with the first statement after the entire try-catch structure. If no catch block matches, the exception propagates up the call stack as if the try-catch were not there. The exception variable declared in the catch clause (catch (IOException e)) is scoped to that catch block and nowhere else. It cannot be used before the catch block (variables in the try block are accessible to code in the catch block only if they were declared before the throw, and only if they are definitely assigned at the declaration point), nor after the catch block. This scoping prevents accidental use of exception state after the exception has been handled.
Java
// ── Basic try-catch structure ────────────────────────────────────────
try {
    // Guarded region — code that might throw
    String text = Files.readString(Path.of("data.txt"));  // throws IOException
    int    value = Integer.parseInt(text.trim());          // throws NumberFormatException
    System.out.println("Value: " + value);                 // runs only if no exception
} catch (IOException e) {
    // Runs if IOException thrown anywhere in try block
    System.err.println("File error: " + e.getMessage());
}
// Execution continues here after try-catch (whether or not catch ran)

// ── Precise throw point — statements AFTER throw do NOT run ───────────
try {
    System.out.println("Before throw");
    throw new RuntimeException("test");
    // System.out.println("After throw");   // unreachable — never runs
} catch (RuntimeException e) {
    System.out.println("Caught: " + e.getMessage());
}
System.out.println("After try-catch");

// Output:
// Before throw
// Caught: test
// After try-catch

// ── Exception variable scoped to catch block ──────────────────────────
try {
    riskyOperation();
} catch (IOException e) {
    System.out.println(e.getMessage());   // e accessible here
}
// System.out.println(e.getMessage());    // COMPILE ERROR — e out of scope

// ── Accessing try block variables in catch ─────────────────────────────
String filename = null;   // declared BEFORE try — accessible in catch
try {
    filename = getFilename();
    Files.readString(Path.of(filename));
} catch (IOException e) {
    System.err.println("Could not read: " + filename);   // filename accessible
}

Exception Type Matching and Supertype Catching

Catch blocks match exceptions using the is-a relationship. A catch block for IOException catches FileNotFoundException, SocketException, and any other IOException subclass — because all of them are IOExceptions. This supertype matching is useful for handling an entire category of exceptions with one block, but it can be too broad if only specific subtypes should be handled. The JVM evaluates catch blocks from top to bottom and executes the first match. This ordering rule has an important implication: a catch block for a supertype must appear after any catch blocks for subtypes; placing the supertype first makes all subtype blocks unreachable, which the compiler detects and reports as a compile error. Catching Exception or Throwable is an anti-pattern for most application code. It catches exceptions the code did not anticipate and cannot meaningfully handle, silencing bugs and making the system harder to debug. An empty catch block — catch (Exception e) {} — is even worse: it discards the exception entirely, hiding failures completely. The correct guideline is to catch the most specific exception type that the code can actually handle, and to always do something meaningful in the catch block — log it, translate it, or recover from it.
Java
// ── Supertype catch — catches all subtypes ───────────────────────────
try {
    Files.readString(Path.of("missing.txt"));
} catch (IOException e) {
    // Catches IOException AND FileNotFoundException AND SocketException
    // (FileNotFoundException extends IOException)
    System.err.println("I/O error: " + e.getMessage());
}

// ── Ordering: specific before general ────────────────────────────────
try {
    Files.readString(Path.of("missing.txt"));
} catch (FileNotFoundException e) {
    // Handles the specific case first
    System.err.println("File not found: " + e.getMessage());
} catch (IOException e) {
    // Handles all other I/O exceptions
    System.err.println("I/O error: " + e.getMessage());
}

// ── Compiler detects unreachable catch blocks ─────────────────────────
try {
    Files.readString(Path.of("file.txt"));
// } catch (IOException e) {
//     ...
// } catch (FileNotFoundException e) {  // COMPILE ERROR
//     // FileNotFoundException IS-A IOException
//     // The IOException block above already catches all FileNotFoundExceptions
//     // This block is UNREACHABLE — compiler rejects it
// }
} catch (IOException e) {
    System.err.println("error: " + e.getMessage());
}

// ── Anti-patterns ─────────────────────────────────────────────────────
// BAD: catching everything silences unexpected bugs
try {
    process();
} catch (Exception e) {
    // Catches NullPointerException, IllegalArgumentException, everything
    // Code has a bug? Swallowed here and hidden.
}

// WORSE: empty catch block — failure is completely invisible
try {
    process();
} catch (Exception e) {
    // Silent failure — program continues in unknown state
}

// GOOD: catch specific, handle meaningfully
try {
    process();
} catch (InsufficientFundsException e) {
    notifyUser("Insufficient funds: " + e.getBalance());
} catch (OrderNotFoundException e) {
    return ResponseEntity.notFound().build();
}

Writing Effective Catch Blocks

A catch block that does nothing is almost always wrong — it hides failures and leaves the program in an unknown state. Every catch block should do at least one of four things: recover and continue normally; log and rethrow the same exception; wrap and rethrow as a different exception; or handle the failure gracefully by returning a default value, notifying the user, or taking corrective action. Logging in catch blocks requires care. The anti-pattern of logging and then rethrowing causes the same exception to appear in the logs multiple times at every level of the call stack that does this, making logs noisy and hard to read. The principle is: log once, at the level where the exception is actually handled (where recovery or user notification happens), not at every intermediate level. Intermediate levels should either rethrow without logging or wrap and rethrow without logging. Exception chaining is critical for debugging. When catching one exception and throwing another, always pass the original exception as the cause: throw new ServiceException("Order processing failed", e). Without the cause, the root cause information is lost, and the logged stack trace stops at the wrapping point instead of showing the actual source of the problem.
Java
// ── Recovery pattern ─────────────────────────────────────────────────
public String readConfig(String path) {
    try {
        return Files.readString(Path.of(path));
    } catch (IOException e) {
        // Recovery: return default config instead of propagating
        log.warn("Config file not found at {}, using defaults", path);
        return DEFAULT_CONFIG;
    }
}

// ── Wrap and rethrow (translate to domain exception) ──────────────────
public Order findOrder(Long id) {
    try {
        return orderRepository.findById(id)
            .orElseThrow(() -> new OrderNotFoundException(id));
    } catch (DataAccessException e) {
        // Translate infrastructure exception to domain exception
        // ALWAYS include the cause — preserves debugging information
        throw new ServiceException("Failed to load order " + id, e);
    }
}

// ── Log at the handling level, not at every level ─────────────────────
// WRONG — log and rethrow at every level creates duplicate log entries
void levelC() throws IOException {
    try { doWork(); }
    catch (IOException e) { log.error("error", e); throw e; }  // logged here
}

void levelB() throws IOException {
    try { levelC(); }
    catch (IOException e) { log.error("error", e); throw e; }  // logged again!
}

void levelA() {
    try { levelB(); }
    catch (IOException e) { log.error("error", e); }           // logged again!!
}
// Result: same error appears 3 times in the log

// CORRECT — let it propagate, log ONCE at the handling level
void levelC_correct() throws IOException { doWork(); }          // no logging
void levelB_correct() throws IOException { levelC_correct(); }  // no logging
void levelA_correct() {
    try { levelB_correct(); }
    catch (IOException e) { log.error("IO failure", e); }       // logged ONCE
}

// ── Preserve exception information ───────────────────────────────────
// BAD: loses root cause
catch (SQLException e) {
    throw new DataException("Database error");  // cause not preserved!
}

// GOOD: chain the cause
catch (SQLException e) {
    throw new DataException("Database error: " + e.getMessage(), e);  // cause preserved
}

Related Topics in Exception Handling

Exception Basics
An exception is an event that disrupts the normal flow of a program during execution. Java's exception system is a structured mechanism for signalling, propagating, and handling error conditions — a significant improvement over the older approach of returning special error codes that callers could silently ignore. Understanding exceptions means understanding the class hierarchy that categorises them, the distinction between checked and unchecked exceptions, what the JVM does when an exception is thrown, how the call stack is unwound, and what information an exception object carries. This entry covers the full exception hierarchy, the checked vs unchecked distinction and the reasoning behind it, exception propagation through the call stack, and the information model of exception objects.
Multiple catch
A single try block can be followed by multiple catch blocks, each handling a different exception type. Multiple catch blocks allow code to respond differently to different kinds of failures — recovering from an expected missing file, but reporting and rethrowing an unexpected database error. Java 7 introduced multi-catch syntax, allowing a single catch block to handle multiple unrelated exception types, eliminating the duplicate code that arises when different exceptions require the same handling. This entry covers the ordering rules, multi-catch syntax, the type of the caught variable in multi-catch, and the design patterns for structuring multiple exception handlers.
finally
The finally block contains code that must execute regardless of whether the try block completed normally, threw an exception that was caught, or threw an exception that was not caught. It is the mechanism for guaranteed cleanup — closing files, releasing locks, returning connections to a pool, rolling back transactions. The finally block runs in all scenarios except two: if the JVM exits (System.exit()) or if the thread is killed by an Error like StackOverflowError. Understanding when exactly finally runs, the interaction between finally and return statements, exception suppression when finally itself throws, and the modern alternative of try-with-resources is essential for writing correct resource management code in Java.
throw
The throw statement explicitly throws an exception object, immediately terminating normal execution and initiating exception propagation. throw is used to signal that a method cannot fulfil its contract because a precondition is violated, an invalid state has been detected, or an operation has failed. Understanding when to throw, what to throw, how to create informative exception objects, and how throwing interacts with the call stack is the foundation of using exceptions as a design tool rather than just an error mechanism. This entry covers the throw statement mechanics, throwing at the right abstraction level, creating informative exception messages, exception chaining, and the principles for deciding when to throw versus returning a special value.