Skip to content
Java exceptions 6 min read

Custom Exceptions

Java’s built-in exceptions cover a lot of ground, but sooner or later you’ll need to signal errors that are specific to your application’s domain — like an invalid bank account balance or a product out of stock. That’s where custom exceptions come in: they let you give errors meaningful names, carry extra context, and make your code far easier to read and debug.

Why Create Custom Exceptions?

Standard exceptions like IllegalArgumentException or RuntimeException are generic. When you throw one, the caller sees a vague message and has to guess what went wrong. A custom exception like InsufficientFundsException immediately tells the whole story.

Benefits of custom exceptions:

  • Clarity — the exception name describes the problem precisely.
  • Extra data — you can add fields (e.g., requiredAmount, availableBalance).
  • Selective catching — callers can catch your specific exception without accidentally swallowing unrelated errors.
  • Separation of concerns — domain errors stay separate from infrastructure errors.

Checked vs Unchecked Custom Exceptions

Before writing code, decide which flavour you need:

TypeExtendsMust be declared / caught?When to use
CheckedExceptionYesRecoverable conditions callers should handle (e.g., file not found, invalid input)
UncheckedRuntimeExceptionNoProgramming errors or unrecoverable situations (e.g., bad arguments, logic bugs)

Tip: Prefer unchecked exceptions for things the caller cannot reasonably recover from, and checked exceptions when you want to force the caller to handle a specific failure path.

Creating a Checked Custom Exception

Extend Exception and provide at least a message constructor. Adding a cause constructor lets you wrap lower-level exceptions without losing their stack trace.

// InsufficientFundsException.java
public class InsufficientFundsException extends Exception {

    private final double required;
    private final double available;

    public InsufficientFundsException(double required, double available) {
        super(String.format("Need %.2f but only %.2f available.", required, available));
        this.required  = required;
        this.available = available;
    }

    // Wrapping constructor — preserves the original cause
    public InsufficientFundsException(double required, double available, Throwable cause) {
        super(String.format("Need %.2f but only %.2f available.", required, available), cause);
        this.required  = required;
        this.available = available;
    }

    public double getRequired()  { return required; }
    public double getAvailable() { return available; }
}

Now use it in a BankAccount class:

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;
        System.out.println("Withdrew " + amount + ". New balance: " + balance);
    }
}

And catch it at the call site:

public class Main {
    public static void main(String[] args) {
        BankAccount account = new BankAccount(100.0);

        try {
            account.withdraw(150.0);
        } catch (InsufficientFundsException e) {
            System.out.println("Transfer failed: " + e.getMessage());
            System.out.printf("Short by: %.2f%n", e.getRequired() - e.getAvailable());
        }
    }
}

Output:

Transfer failed: Need 150.00 but only 100.00 available.
Short by: 50.00

Because InsufficientFundsException is checked, the compiler forces you to either declare it with throws or surround the call with a try-catch block — you can’t accidentally ignore it.

Creating an Unchecked Custom Exception

Extend RuntimeException instead. No throws declaration is needed on method signatures.

// InvalidAgeException.java
public class InvalidAgeException extends RuntimeException {

    private final int age;

    public InvalidAgeException(int age) {
        super("Age must be between 0 and 150, but got: " + age);
        this.age = age;
    }

    public int getAge() { return age; }
}
public class Voter {

    public static void register(String name, int age) {
        if (age < 18 || age > 150) {
            throw new InvalidAgeException(age);
        }
        System.out.println(name + " registered successfully.");
    }

    public static void main(String[] args) {
        register("Alice", 25);   // works fine
        register("Bob", 15);     // throws InvalidAgeException
    }
}

Output:

Alice registered successfully.
Exception in thread "main" InvalidAgeException: Age must be between 0 and 150, but got: 15

Note: Even though you don’t have to declare unchecked exceptions, you should still document them in Javadoc @throws tags so callers know what to expect.

Building a Small Exception Hierarchy

For larger applications, it pays to create a base exception for your module and then subclass it. Callers can catch the base type to handle all module errors, or catch a subclass for fine-grained handling.

// Base exception for the payments module
public class PaymentException extends Exception {
    public PaymentException(String message) { super(message); }
    public PaymentException(String message, Throwable cause) { super(message, cause); }
}

// Specific subtypes
public class CardDeclinedException extends PaymentException {
    public CardDeclinedException(String cardLast4) {
        super("Card ending in " + cardLast4 + " was declined.");
    }
}

public class PaymentTimeoutException extends PaymentException {
    public PaymentTimeoutException() {
        super("Payment gateway timed out. Please try again.");
    }
}

Catching at different levels:

try {
    processPayment(order);
} catch (CardDeclinedException e) {
    // Handle specifically — prompt user for another card
    System.out.println(e.getMessage());
} catch (PaymentException e) {
    // Handle all other payment failures generically
    System.out.println("Payment failed: " + e.getMessage());
}

See multiple catch blocks for more on catching multiple exception types.

Re-throwing and Wrapping Exceptions

A common pattern is to catch a low-level exception and wrap it in a custom one — this preserves the original cause while surfacing a cleaner API to callers.

public class OrderService {

    public void placeOrder(int orderId) throws OrderProcessingException {
        try {
            // imagine this calls a database or external API
            loadOrder(orderId);
        } catch (Exception e) {
            // wrap and rethrow with context
            throw new OrderProcessingException("Failed to place order #" + orderId, e);
        }
    }

    private void loadOrder(int id) throws Exception {
        throw new Exception("DB connection lost"); // simulated low-level failure
    }
}

class OrderProcessingException extends Exception {
    public OrderProcessingException(String message, Throwable cause) {
        super(message, cause);
    }
}

Always pass the original exception as the cause. If you drop it, you lose the root-cause stack trace — a debugging nightmare.

Under the Hood

Custom exceptions are ordinary Java classes — no special bytecode treatment. When you call throw, the JVM:

  1. Allocates the exception object on the heap and captures the current stack trace (via Throwable.fillInStackTrace(), which walks the native call stack).
  2. Unwinds the call stack frame by frame, looking for a matching catch block. Each frame check is O(1), so unwinding is proportional to stack depth.
  3. Executes the matching catch block, then continues after the surrounding try-catch-finally.

Capturing the stack trace is the expensive part — it involves a native JVM call. For hot paths where exceptions are expected (e.g., parsing user input in a loop), consider a sentinel return value or Optional instead. For truly exceptional cases, stack-trace cost is negligible.

Since Java 7 you can override fillInStackTrace() to return this and skip stack capture entirely:

public class FastSignalException extends RuntimeException {
    public FastSignalException(String message) { super(message); }

    @Override
    public synchronized Throwable fillInStackTrace() {
        return this; // no stack trace captured — faster but harder to debug
    }
}

Use this only for control-flow-style exceptions where speed matters and you already know the origin.

Warning: Suppressing the stack trace makes debugging significantly harder. Only do this for performance-critical, well-understood scenarios.

Best Practices Checklist

  • Name exceptions as nouns ending in Exception (e.g., UserNotFoundException, not UserNotFound).
  • Always provide both a message-only constructor and a message-plus-cause constructor.
  • Add typed fields for extra context — don’t just stuff everything into the message string.
  • Prefer unchecked exceptions for programming errors; checked for recoverable domain errors.
  • Document with @throws in Javadoc regardless of checked vs unchecked.
  • Never catch (Exception e) {} silently — at minimum log the exception.
  • Build a hierarchy for larger modules so callers can catch broadly or narrowly as needed.
Last updated June 13, 2026
Was this helpful?