Skip to main content
Java intermediate Lesson 27 of 58

Exception Handling in Java

Learn how Java handles errors — the exception hierarchy, try/catch/finally, checked vs unchecked exceptions, throw/throws, and creating custom exceptions.

Exceptions are Java’s mechanism for handling errors at runtime. Instead of returning error codes that callers can silently ignore, Java throws exception objects that propagate up the call stack until they’re caught or crash the program with a clear message and stack trace. This makes error conditions hard to ignore and gives you a structured way to separate normal logic from error-handling logic.

The Exception Hierarchy

Understanding the hierarchy tells you what you must handle, what you should handle, and what you should never catch. Checked exceptions represent conditions a caller can reasonably anticipate and recover from. Unchecked exceptions are almost always programming bugs. Errors are JVM-level failures you can do nothing about.

Throwable
├── Error              (JVM problems — never catch these)
│   ├── OutOfMemoryError
│   ├── StackOverflowError
│   └── ...
└── Exception
    ├── IOException           ← checked: compiler requires handling
    ├── SQLException          ← checked: compiler requires handling
    ├── ClassNotFoundException ← checked: compiler requires handling
    └── RuntimeException      ← unchecked: no compiler requirement
        ├── NullPointerException
        ├── ArrayIndexOutOfBoundsException
        ├── IllegalArgumentException
        ├── IllegalStateException
        ├── NumberFormatException
        └── ...
  • Checked exceptions — compiler requires you to handle or declare them
  • Unchecked exceptions (RuntimeException subclasses) — compiler does not require handling
  • Errors — serious JVM failures; never catch these

try / catch / finally

The try block contains code that might throw. The catch block handles a specific exception type. The finally block always runs — whether the try completed normally, threw an exception, or returned early — making it the right place for cleanup code like closing resources.

try {
    // code that might throw
    int result = 10 / 0;
} catch (ArithmeticException e) {
    // handle the specific exception — e carries the message and stack trace
    System.out.println("Cannot divide by zero: " + e.getMessage());
} finally {
    // always runs — use for cleanup (but prefer try-with-resources for resources)
    System.out.println("Cleanup happens here");
}

Multiple catch Blocks

You can catch several exception types from the same try block. Always order catch blocks from most specific to most general — a more general type listed first would swallow the specific ones and prevent them from being reached.

public static int parseAndDivide(String a, String b) {
    try {
        int x = Integer.parseInt(a);  // may throw NumberFormatException
        int y = Integer.parseInt(b);
        return x / y;                 // may throw ArithmeticException
    } catch (NumberFormatException e) {
        // most specific first — handles non-numeric input
        System.err.println("Not a valid number: " + e.getMessage());
        return 0;
    } catch (ArithmeticException e) {
        // handles division by zero
        System.err.println("Division by zero");
        return 0;
    }
}

Multi-catch (Java 7+)

When two exception types warrant exactly the same handling, the multi-catch syntax eliminates duplication without sacrificing specificity.

try {
    // ...
} catch (IOException | SQLException e) {
    // handles both types the same way — e is effectively final here
    System.err.println("Data error: " + e.getMessage());
}

Exception Information

Every exception carries a message, its class name, and a full stack trace. The stack trace is the most useful debugging tool — it shows exactly which method calls led to the error.

try {
    int[] arr = new int[3];
    arr[10] = 1;
} catch (ArrayIndexOutOfBoundsException e) {
    System.out.println(e.getMessage());         // "Index 10 out of bounds for length 3"
    System.out.println(e.getClass().getName()); // "java.lang.ArrayIndexOutOfBoundsException"
    e.printStackTrace(); // full stack trace to stderr — essential for debugging
}

throw — Raising an Exception

Use throw to raise an exception from your own code when a precondition is violated or a situation is unrecoverable. Throwing a specific, well-named exception with a clear message makes it obvious to the caller what went wrong and what they passed in.

public static double sqrt(double n) {
    if (n < 0) {
        // IllegalArgumentException is the standard unchecked exception for bad arguments
        throw new IllegalArgumentException("Cannot take sqrt of negative number: " + n);
    }
    return Math.sqrt(n);
}

// Calling code:
try {
    System.out.println(sqrt(-4));
} catch (IllegalArgumentException e) {
    System.out.println(e.getMessage());
    // Cannot take sqrt of negative number: -4.0
}

throws — Declaring Checked Exceptions

If a method can throw a checked exception and doesn’t catch it internally, it must declare it with throws. This is the compiler’s way of forcing the contract to be explicit: every caller knows they need to handle or propagate the exception.

import java.io.*;

// The throws declaration is part of the method's public contract
public static String readFile(String path) throws IOException {
    StringBuilder sb = new StringBuilder();
    try (BufferedReader br = new BufferedReader(new FileReader(path))) {
        String line;
        while ((line = br.readLine()) != null) sb.append(line).append("\n");
    }
    return sb.toString();
}

// Caller must handle it — the compiler won't let you forget
try {
    String content = readFile("data.txt");
    System.out.println(content);
} catch (IOException e) {
    System.err.println("Could not read file: " + e.getMessage());
}

try-with-resources

Before Java 7, closing resources (files, database connections, streams) correctly required verbose, error-prone finally blocks. try-with-resources solves this cleanly: any object implementing AutoCloseable is automatically closed when the try block exits, whether normally or via exception. This eliminates an entire class of resource-leak bugs.

// Without try-with-resources — verbose and error-prone
BufferedReader br = null;
try {
    br = new BufferedReader(new FileReader("file.txt"));
    System.out.println(br.readLine());
} catch (IOException e) {
    e.printStackTrace();
} finally {
    if (br != null) try { br.close(); } catch (IOException e) { /* swallowed */ }
}

// With try-with-resources — clean, resource always closed even on exception
try (BufferedReader br = new BufferedReader(new FileReader("file.txt"))) {
    System.out.println(br.readLine());
} catch (IOException e) {
    e.printStackTrace();
}

// Multiple resources — closed in reverse order of declaration
try (FileInputStream in  = new FileInputStream("input.txt");
     FileOutputStream out = new FileOutputStream("output.txt")) {
    // both streams are guaranteed to close when the block exits
}

Custom Exceptions

Creating your own exception classes lets you express domain-specific error conditions precisely. A InsufficientFundsException carries more information and intent than a generic RuntimeException("not enough money"). Callers can catch your specific type and access the structured data you attach to it.

// Checked custom exception — caller is forced to handle it
public class InsufficientFundsException extends Exception {
    private final double amount;
    private final double balance;

    public InsufficientFundsException(double amount, double balance) {
        super(String.format("Cannot withdraw %.2f — balance is %.2f", amount, balance));
        this.amount  = amount;
        this.balance = balance;
    }

    // Structured data callers can use for decisions or display
    public double getAmount()  { return amount; }
    public double getBalance() { return balance; }
}

// Unchecked custom exception — for programming errors or unrecoverable states
public class InvalidAgeException extends RuntimeException {
    public InvalidAgeException(int age) {
        super("Invalid age: " + age + " (must be 0–150)");
    }
}

Using them:

public class BankAccount {
    private double balance;

    public BankAccount(double initialBalance) { this.balance = initialBalance; }

    public void withdraw(double amount) throws InsufficientFundsException {
        if (amount > balance) throw new InsufficientFundsException(amount, balance);
        balance -= amount;
    }

    public double getBalance() { return balance; }
}

BankAccount account = new BankAccount(100.0);
try {
    account.withdraw(150.0);
} catch (InsufficientFundsException e) {
    System.out.println(e.getMessage());
    System.out.printf("Shortfall: %.2f%n", e.getAmount() - e.getBalance());
}

Exception Chaining

When you catch a low-level exception and re-throw a higher-level one, always pass the original as the cause. Exception chaining preserves the full diagnostic picture — you can see both the domain-level error and the underlying technical cause in the stack trace. Dropping the cause is a common mistake that makes bugs very hard to diagnose in production.

public static User loadUser(int id) throws ServiceException {
    try {
        return database.findUser(id);
    } catch (SQLException e) {
        // Wrap with the domain exception, but keep the original cause
        throw new ServiceException("Failed to load user #" + id, e);
    }
}

// In the caller, you can inspect both levels of the chain
try {
    User u = loadUser(42);
} catch (ServiceException e) {
    System.err.println(e.getMessage());                    // domain error
    System.err.println("Caused by: " + e.getCause().getMessage()); // technical cause
}

Best Practices

// Never swallow exceptions silently — bugs disappear and are never fixed
try {
    riskyOperation();
} catch (Exception e) {
    // WRONG — the exception is gone, the bug is invisible
}

// Log or re-throw — always do one or the other
try {
    riskyOperation();
} catch (IOException e) {
    logger.error("Operation failed", e); // log with full stack trace
    throw new ServiceException("Could not complete operation", e); // or wrap and rethrow
}

// Don't use exceptions for normal flow control — they are expensive and confusing
// WRONG:
try {
    return Integer.parseInt(s);
} catch (NumberFormatException e) {
    return -1; // using exception as a branch is a code smell
}
// Better: check first, then parse
if (s.matches("-?\\d+")) return Integer.parseInt(s);
else return -1;

Project: Banking Application

public class BankingApp {

    public static class Account {
        private final String id;
        private double balance;

        public Account(String id, double initialBalance) {
            if (initialBalance < 0) throw new IllegalArgumentException("Initial balance cannot be negative");
            this.id = id;
            this.balance = initialBalance;
        }

        public void deposit(double amount) {
            if (amount <= 0) throw new IllegalArgumentException("Deposit amount must be positive");
            balance += amount;
        }

        public void withdraw(double amount) throws InsufficientFundsException {
            if (amount <= 0) throw new IllegalArgumentException("Withdrawal amount must be positive");
            if (amount > balance) throw new InsufficientFundsException(amount, balance);
            balance -= amount;
        }

        public void transfer(Account target, double amount) throws InsufficientFundsException {
            withdraw(amount);       // throws if insufficient — transfer is atomic
            target.deposit(amount); // only reached if withdraw succeeded
        }

        public double getBalance() { return balance; }
        public String getId()      { return id; }

        @Override public String toString() {
            return String.format("Account[%s, balance=%.2f]", id, balance);
        }
    }

    public static void main(String[] args) {
        Account alice = new Account("ALICE-001", 1000.0);
        Account bob   = new Account("BOB-001",   500.0);

        try {
            alice.transfer(bob, 300.0);
            System.out.println("Transfer successful");
            System.out.println(alice);
            System.out.println(bob);

            alice.withdraw(800.0); // will throw — only 700 left
        } catch (InsufficientFundsException e) {
            System.out.println("Transfer failed: " + e.getMessage());
        } catch (IllegalArgumentException e) {
            System.out.println("Invalid input: " + e.getMessage());
        }
    }
}

Frequently Asked Questions

What is the difference between checked and unchecked exceptions?
Checked exceptions (like IOException, SQLException) must be declared with throws or caught — the compiler enforces this. Unchecked exceptions (RuntimeException subclasses like NullPointerException, ArrayIndexOutOfBoundsException) don't require declaration. Use checked exceptions for recoverable conditions the caller should handle; use unchecked for programming errors.
When should I use throw vs throws?
throw (no s) is a statement that actually raises an exception: throw new IllegalArgumentException(). throws (with s) is part of a method signature declaring that the method may propagate a checked exception: public void readFile() throws IOException.
Should I catch Exception or RuntimeException as a catch-all?
Avoid it. Catching broad exceptions swallows bugs you don't know about. Catch the most specific exception type you can handle meaningfully. If you must catch broadly (at a top-level handler), log the full stack trace and re-throw or fail fast.