Exceptions #

Every program that interacts with the real world — reading files, calling APIs, receiving user input — will inevitably face unexpected conditions. A file doesn’t exist. A database connection times out. A user types letters into a field that should contain numbers. Exceptions are how Java models these conditions as objects that can be caught, inspected, and handled in a structured way. Without exception handling, a single small error can bring down your entire program. With good exception handling, a program can recover from failures, log useful information for debugging, and inform users with sensible messages. This article covers exceptions from the basics of the hierarchy, how to catch and throw them, to the patterns used in real production code.

The Exception Hierarchy #

Every exception in Java is an object. They all inherit from a single top-level class: Throwable. Understanding this hierarchy matters because it determines how you should handle each type of error.

flowchart TD
    A[Throwable] --> B[Error]
    A --> C[Exception]
    B --> D[OutOfMemoryError]
    B --> E[StackOverflowError]
    B --> F[VirtualMachineError]
    C --> G[RuntimeException\nUnchecked]
    C --> H[IOException\nChecked]
    C --> I[SQLException\nChecked]
    G --> J[NullPointerException]
    G --> K[ArrayIndexOutOfBoundsException]
    G --> L[IllegalArgumentException]
    G --> M[ArithmeticException]

There are three main categories you need to distinguish:

CategoryExamplesMust be handled?Common causes
ErrorOutOfMemoryError, StackOverflowErrorNoJVM ran out of resources — usually unrecoverable
Checked ExceptionIOException, SQLExceptionYesAnticipatable external conditions (missing file, DB down)
Unchecked ExceptionNullPointerException, IllegalArgumentExceptionNoBugs in program logic — should be prevented, not caught

You don’t need to — and shouldn’t — catch Error; when the JVM runs out of memory, there’s nothing you can do. Checked exceptions must be caught or declared with throws. Unchecked exceptions (subclasses of RuntimeException) are bug signals — it’s better to fix your code than to catch the exception.


try-catch: Catching Exceptions #

The try-catch block is the fundamental structure of exception handling. Code that might throw an exception goes inside try, and its handling goes inside catch.

public class TryCatchExample {
    public static void main(String[] args) {
        // ANTI-PATTERN: no handling at all
        // int[] numbers = {1, 2, 3};
        // System.out.println(numbers[5]);
        // → program crashes with a scary stack trace

        // CORRECT: catch the exception and handle it meaningfully
        int[] numbers = {1, 2, 3};

        try {
            System.out.println("4th element: " + numbers[3]);
        } catch (ArrayIndexOutOfBoundsException e) {
            System.out.println("Invalid index. The array only has " + numbers.length + " elements.");
        }

        System.out.println("Program keeps running.");
    }
}

The catch block receives the exception object as a parameter. From this object you can retrieve important information:

try {
    String text = null;
    System.out.println(text.length()); // NullPointerException
} catch (NullPointerException e) {
    System.out.println("Message: " + e.getMessage());
    System.out.println("Type: " + e.getClass().getName());
    e.printStackTrace(); // prints the entire call stack to stderr — useful for debugging
}

try-catch-finally: Cleaning Up Resources #

The finally block always runs — whether an exception occurred or not. This is the right place to close resources like files, database connections, or network streams.

import java.io.*;

public class FinallyExample {
    public static void readFile(String fileName) {
        BufferedReader reader = null;

        try {
            reader = new BufferedReader(new FileReader(fileName));
            String line;
            while ((line = reader.readLine()) != null) {
                System.out.println(line);
            }
        } catch (FileNotFoundException e) {
            System.out.println("File not found: " + fileName);
        } catch (IOException e) {
            System.out.println("Failed to read file: " + e.getMessage());
        } finally {
            // Always runs — make sure the reader is closed
            if (reader != null) {
                try {
                    reader.close();
                    System.out.println("File closed.");
                } catch (IOException e) {
                    System.out.println("Failed to close file: " + e.getMessage());
                }
            }
        }
    }

    public static void main(String[] args) {
        readFile("data.txt");
    }
}

Notice how the finally block has to close the reader while also handling the IOException from close(). This is verbose and easy to get wrong. Java 7 introduced a much cleaner solution.


try-with-resources: The Modern Solution for Resources #

try-with-resources automatically closes resources when the try block finishes — whether normally or due to an exception. Resources you can use are objects that implement the AutoCloseable or Closeable interface.

import java.io.*;

public class TryWithResourcesExample {

    // ANTI-PATTERN: manually closing resources in finally — verbose and bug-prone
    public static String readFileManual(String path) throws IOException {
        BufferedReader reader = null;
        try {
            reader = new BufferedReader(new FileReader(path));
            return reader.readLine();
        } finally {
            if (reader != null) reader.close(); // easy to forget
        }
    }

    // CORRECT: try-with-resources — the reader is closed automatically, much cleaner
    public static String readFileModern(String path) throws IOException {
        try (BufferedReader reader = new BufferedReader(new FileReader(path))) {
            return reader.readLine();
        }
        // reader.close() is called automatically here, even if an exception occurs
    }

    // You can also open several resources at once
    public static void copyFile(String source, String destination) throws IOException {
        try (
            BufferedReader reader = new BufferedReader(new FileReader(source));
            BufferedWriter writer = new BufferedWriter(new FileWriter(destination))
        ) {
            String line;
            while ((line = reader.readLine()) != null) {
                writer.write(line);
                writer.newLine();
            }
            System.out.println("File copied successfully.");
        }
        // both reader and writer are closed automatically, in reverse order of opening
    }

    public static void main(String[] args) {
        try {
            String firstLine = readFileModern("input.txt");
            System.out.println("First line: " + firstLine);
            copyFile("input.txt", "output.txt");
        } catch (IOException e) {
            System.out.println("File operation failed: " + e.getMessage());
        }
    }
}
Always use try-with-resources for resources that need closing (files, connections, streams). It’s safer than manual finally because you can’t forget it, and it handles edge cases like exceptions inside close() better.

Multi-catch: Catching Multiple Exceptions #

A single try block can produce several different types of exceptions. There are two ways to handle them: separate catch blocks per type, or multi-catch with the | operator.

import java.io.*;
import java.sql.*;

public class MultiCatchExample {

    public static void processData(String fileName, String sqlQuery) {
        // Catch each exception separately when the handling differs
        try {
            BufferedReader reader = new BufferedReader(new FileReader(fileName));
            // ... process file
        } catch (FileNotFoundException e) {
            System.out.println("File not found: " + fileName);
            // maybe create a new file or look in another location
        } catch (IOException e) {
            System.out.println("Failed to read file: " + e.getMessage());
            // maybe retry or log and skip
        }

        // ANTI-PATTERN: catching Exception or Throwable too broadly
        // catch (Exception e) { ... }
        // → catches everything, including bugs you should fix

        // CORRECT: multi-catch for different exceptions with the SAME handling
        try {
            Connection conn = DriverManager.getConnection("jdbc:mysql://localhost/db");
            Statement stmt = conn.createStatement();
            stmt.execute(sqlQuery);
        } catch (SQLException | IllegalArgumentException e) {
            // Both of these exceptions need to be logged and notify the admin
            System.out.println("Database error: " + e.getMessage());
            notifyAdmin(e);
        }
    }

    static void notifyAdmin(Exception e) {
        System.out.println("[ADMIN NOTIF] " + e.getClass().getSimpleName() + ": " + e.getMessage());
    }
}

The order of catch blocks also matters — catch the more specific ones first, then the more general ones. If you put catch (Exception e) before catch (IOException e), the code won’t compile because IOException is already covered by Exception.

// ANTI-PATTERN: wrong catch order — the specific exception is never reached
try {
    // code
} catch (Exception e) {          // too broad, catches everything
    // ...
} catch (IOException e) {        // error: IOException already caught above
    // ...
}

// CORRECT: specific first, general later
try {
    // code
} catch (FileNotFoundException e) {  // most specific
    // ...
} catch (IOException e) {            // more general than FileNotFoundException
    // ...
} catch (Exception e) {              // most general, as a safety net
    // ...
}

throws: Delegating the Handling #

Not every method has to handle exceptions itself. Sometimes it makes more sense to delegate the handling to the caller — especially when the caller has better context to decide what should be done.

Use throws in the method signature to tell the compiler that this method can throw a particular checked exception.

import java.io.*;

public class ReportProcessor {

    // This method doesn't know what to do if the file doesn't exist
    // — better to throw it to the caller who knows the context
    public String readTemplate(String path) throws IOException {
        try (BufferedReader reader = new BufferedReader(new FileReader(path))) {
            StringBuilder sb = new StringBuilder();
            String line;
            while ((line = reader.readLine()) != null) {
                sb.append(line).append("\n");
            }
            return sb.toString();
        }
    }

    public String buildReport(String templateName, String userData) throws IOException {
        String template = readTemplate(templateName); // the exception propagates here
        return template.replace("{{name}}", userData);
    }
}

public class Main {
    public static void main(String[] args) {
        ReportProcessor processor = new ReportProcessor();

        // The caller has the context: it knows to fall back to a default template
        try {
            String report = processor.buildReport("custom-template.txt", "Budi Santoso");
            System.out.println(report);
        } catch (FileNotFoundException e) {
            System.out.println("Template not found, using the default template.");
            // use a hardcoded template as fallback
        } catch (IOException e) {
            System.out.println("Failed to build report: " + e.getMessage());
        }
    }
}
sequenceDiagram
    participant Main
    participant ReportProcessor
    participant readTemplate

    Main->>ReportProcessor: buildReport("template.txt", "Budi")
    ReportProcessor->>readTemplate: readTemplate("template.txt")
    readTemplate-->>ReportProcessor: throws IOException
    ReportProcessor-->>Main: throws IOException (propagate)
    Main->>Main: catch IOException → handle here

Custom Exceptions: Domain-Specific Exceptions #

Java provides many built-in exceptions, but for your application’s business domain, custom exceptions make the code far more expressive and easier to debug.

// Base exception for the application domain — all business exceptions derive from this
public class AppException extends RuntimeException {
    private final String errorCode;

    public AppException(String errorCode, String message) {
        super(message);
        this.errorCode = errorCode;
    }

    public AppException(String errorCode, String message, Throwable cause) {
        super(message, cause);
        this.errorCode = errorCode;
    }

    public String getErrorCode() {
        return errorCode;
    }
}

// Domain-specific exceptions
public class UserNotFoundException extends AppException {
    private final long userId;

    public UserNotFoundException(long userId) {
        super("USR-404", "User with ID " + userId + " not found.");
        this.userId = userId;
    }

    public long getUserId() {
        return userId;
    }
}

public class InsufficientBalanceException extends AppException {
    private final double currentBalance;
    private final double requestedAmount;

    public InsufficientBalanceException(double currentBalance, double requestedAmount) {
        super("TRX-402",
            "Insufficient balance. Balance: $" + currentBalance + ", Required: $" + requestedAmount);
        this.currentBalance = currentBalance;
        this.requestedAmount = requestedAmount;
    }

    public double getCurrentBalance() { return currentBalance; }
    public double getRequestedAmount() { return requestedAmount; }
}

public class ValidationException extends AppException {
    private final String fieldName;

    public ValidationException(String fieldName, String message) {
        super("VAL-400", "Validation failed on field '" + fieldName + "': " + message);
        this.fieldName = fieldName;
    }

    public String getFieldName() { return fieldName; }
}
public class TransferService {

    public void transfer(long senderId, long receiverId, double amount) {
        // Input validation — throw ValidationException if invalid
        if (amount <= 0) {
            throw new ValidationException("amount", "must be greater than 0");
        }
        if (senderId == receiverId) {
            throw new ValidationException("receiverId", "must not be the same as the sender");
        }

        BankAccount sender = findAccount(senderId);   // throws UserNotFoundException if missing
        BankAccount receiver = findAccount(receiverId);

        if (sender.getBalance() < amount) {
            throw new InsufficientBalanceException(sender.getBalance(), amount);
        }

        sender.decreaseBalance(amount);
        receiver.increaseBalance(amount);
        System.out.println("Transfer of $" + amount + " successful.");
    }

    private BankAccount findAccount(long id) {
        // Simulated account lookup
        if (id <= 0) throw new UserNotFoundException(id);
        return new BankAccount(id, 1000000);
    }
}

public class Main {
    public static void main(String[] args) {
        TransferService service = new TransferService();

        try {
            service.transfer(1L, 2L, 500000);
        } catch (UserNotFoundException e) {
            System.out.println("[" + e.getErrorCode() + "] " + e.getMessage());
            System.out.println("User ID: " + e.getUserId());
        } catch (InsufficientBalanceException e) {
            System.out.println("[" + e.getErrorCode() + "] " + e.getMessage());
            System.out.println("Shortfall: $" + (e.getRequestedAmount() - e.getCurrentBalance()));
        } catch (ValidationException e) {
            System.out.println("[" + e.getErrorCode() + "] " + e.getMessage());
            System.out.println("Check field: " + e.getFieldName());
        } catch (AppException e) {
            // Safety net for all other business exceptions
            System.out.println("[" + e.getErrorCode() + "] Unexpected error: " + e.getMessage());
        }
    }
}

Chained Exceptions: Tracing the Root Cause #

When catching an exception and throwing a new one, always include the original exception as the cause. This preserves the entire error trail so you can trace the root cause when debugging.

import java.io.*;
import java.sql.*;

public class UserDatabaseRepo {

    public String getUserName(long id) {
        // ANTI-PATTERN: swallowing the original exception — information is lost
        // try {
        //     // database query
        // } catch (SQLException e) {
        //     throw new AppException("DB-500", "Failed to get user");
        //     // → the original SQLException stack trace is lost forever
        // }

        // CORRECT: include the original exception as the cause
        try {
            Connection conn = DriverManager.getConnection("jdbc:mysql://localhost/db");
            PreparedStatement stmt = conn.prepareStatement("SELECT name FROM users WHERE id = ?");
            stmt.setLong(1, id);
            ResultSet rs = stmt.executeQuery();
            if (rs.next()) return rs.getString("name");
            throw new UserNotFoundException(id);
        } catch (SQLException e) {
            // Wrap the SQLException in a domain exception, but keep the cause
            throw new AppException("DB-500",
                "Failed to fetch data for user ID " + id + " from the database.", e);
            //                                                                  ↑ this is the cause
        }
    }
}

With the cause preserved, when this exception is logged, e.getCause() will show the original SQLException along with its message and stack trace. Without the cause, you only know “there was a database error” — without knowing which query or for what reason.


Exception Patterns in Production Code #

In real applications, a few patterns are consistently used for clean, maintainable exception handling.

Catch at the outermost layer, not everywhere #

// ANTI-PATTERN: catching the exception in every method then rethrowing it
public String getUserData(long id) {
    try {
        return repo.getUserName(id);
    } catch (AppException e) {
        System.out.println("Error: " + e.getMessage()); // log here...
        throw e; // ...then rethrow — duplicated logging
    }
}

// CORRECT: let the exception propagate, catch it once at the Controller/Handler layer
public class UserController {
    private UserService service = new UserService();

    public void showUser(long id) {
        // The exception is only caught here — one place, one log
        try {
            String name = service.getName(id);
            System.out.println("User: " + name);
        } catch (UserNotFoundException e) {
            System.out.println("404: " + e.getMessage());
        } catch (AppException e) {
            System.out.println("500: Internal error — " + e.getErrorCode());
            e.printStackTrace();
        }
    }
}

Don’t use exceptions for flow control #

// ANTI-PATTERN: using exceptions for ordinary conditional logic
public boolean userExists(long id) {
    try {
        repo.getUserName(id);
        return true;
    } catch (UserNotFoundException e) {
        return false; // exception used as if-else — slow and unidiomatic
    }
}

// CORRECT: use a return value or Optional for cases that legitimately may not exist
public boolean userExists(long id) {
    return repo.existsById(id); // a method that returns boolean
}

// Or use Optional<T>
import java.util.Optional;

public Optional<String> findUserName(long id) {
    try {
        return Optional.of(repo.getUserName(id));
    } catch (UserNotFoundException e) {
        return Optional.empty();
    }
}

Log with context, not just a message #

// ANTI-PATTERN: logging without context — hard to debug in production
catch (SQLException e) {
    System.out.println("Database error");  // useless
}

// CORRECT: log with enough context to reproduce the problem
catch (SQLException e) {
    System.out.printf(
        "Database query failed [table=users, operation=SELECT, id=%d]: %s%n",
        userId, e.getMessage()
    );
    e.printStackTrace(); // or use a logger like SLF4J/Logback in production
}

When to Use Checked vs Unchecked Exceptions #

Use CHECKED EXCEPTIONS when:
  ✓ The error condition can be anticipated and the caller can do something about it
    (file missing → the user can be asked to pick another file)
  ✓ The error comes from external factors (I/O, network, database)
  ✓ You want the compiler to force callers to handle the error

Use UNCHECKED EXCEPTIONS (RuntimeException) when:
  ✓ The error is the result of a bug in the code — it should be prevented, not caught
    (null pointer, negative index, invalid argument)
  ✓ Throwing a checked exception would force many intermediate methods
    to declare throws clauses irrelevant to them
  ✓ You're building business domain exceptions that will be caught at the outermost layer

DON'T:
  ✗ Catch Exception or Throwable generically except in a global error handler
  ✗ Catch an exception and ignore it (empty catch or just a print)
  ✗ Use exceptions as a return value mechanism or flow control
  ✗ Throw a new exception without including the cause of the original exception

Summary #

  • Exceptions are objects that inherit from Throwable. The main hierarchy: Error (don’t catch), checked Exception (must handle), RuntimeException / unchecked (bug signals).
  • try-catch-finally is the basic block. finally always runs — suitable for resource cleanup, but prefer try-with-resources.
  • try-with-resources (Java 7+) closes resources automatically. Always use it for files, connections, and streams — you can’t forget it, and it’s not bug-prone.
  • Multi-catch with | simplifies code when several exceptions need the same handling. Catch the most specific first, then the more general ones.
  • throws delegates the handling to the caller who has better context. Use it when a method doesn’t know what to do when an error occurs.
  • Custom exceptions make code more expressive. Build a per-domain exception hierarchy (AppException as the base), include relevant data (error codes, failed fields).
  • Chained exceptions preserve the root cause. Always include the cause (throw new AppException("msg", e)) when wrapping an exception — don’t swallow the original information.
  • Catch at the outermost layer — let exceptions propagate naturally, catch once in the controller or handler. Avoid catch-and-rethrow patterns that only duplicate logging.

← Previous: Comments   Next: Variables →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact