Java Custom Exceptions
A custom exception is a class you write yourself, usually by extending Exception or RuntimeException, so your program can signal a specific, meaningful failure instead of throwing a generic Exception or relying on an error-code return value. Custom exceptions let you attach domain-specific data to a failure (like an invalid age, an order ID, or a balance), document precisely what can go wrong in a method’s signature, and give callers a distinct type to catch instead of parsing an error message string. Nearly every serious Java codebase defines its own exception types for this reason — a banking app might throw InsufficientFundsException, a web service might throw UserNotFoundException, and a parser might throw MalformedConfigException.
Overview: How Custom Exceptions Work
Every exception in Java is an object, and every exception class ultimately extends java.lang.Throwable. Throwable has two direct subclasses that matter here: Error (serious problems like OutOfMemoryError that applications normally don’t catch) and Exception (problems a program is expected to handle). Under Exception, Java draws a further distinction:
- Checked exceptions — any class that extends
Exceptiondirectly (and is not aRuntimeException). The compiler forces callers to either catch it or declare it withthrows. Use these for recoverable, expected failures the caller should be forced to think about — a missing file, a failed network call, invalid input from outside the program. - Unchecked exceptions — any class that extends
RuntimeException. The compiler does not require handling. Use these for programming errors or conditions that usually indicate a bug — an illegal argument, an invalid state, a violated precondition.
When you write class InsufficientFundsException extends Exception, you inherit everything Throwable already provides: a message field, a cause field (for chaining exceptions), and — most importantly — a captured stack trace. The JVM fills in the stack trace automatically inside the Throwable constructor, at the exact moment the exception object is created with new, not when it is thrown. That’s why creating an exception far from where you throw it produces a misleading stack trace — always construct the exception right where you throw it.
A custom exception class typically adds nothing but constructors — it borrows all of its behavior (getMessage(), getCause(), printStackTrace(), getStackTrace()) from Throwable. You can add your own fields (an error code, an offending value, an entity ID) when the caller needs more than a message to react programmatically.
Syntax
The general shape of a custom exception is:
class MyException extends Exception { // or extends RuntimeException
public MyException(String message) {
super(message);
}
public MyException(String message, Throwable cause) {
super(message, cause);
}
}
| Part | Meaning |
|---|---|
extends Exception |
Makes this a checked exception; callers must catch it or declare throws. |
extends RuntimeException |
Makes this an unchecked exception; no compiler-enforced handling. |
super(message) |
Passes the human-readable description up to Throwable, so getMessage() returns it. |
super(message, cause) |
Chains this exception to the original Throwable that triggered it, preserving the original stack trace via getCause(). |
| Extra fields | Optional — store structured data (an ID, a code, an invalid value) the catcher can use programmatically. |
Examples
Example 1: A checked custom exception
Checked exceptions are appropriate when the caller can reasonably be expected to recover — here, a bank account refuses to overdraw.
class InsufficientFundsException extends Exception {
public InsufficientFundsException(String message) {
super(message);
}
}
class BankAccount {
private double balance;
public BankAccount(double balance) {
this.balance = balance;
}
public void withdraw(double amount) throws InsufficientFundsException {
if (amount > balance) {
throw new InsufficientFundsException(
"Cannot withdraw $" + amount + ", balance is only $" + balance);
}
balance -= amount;
System.out.println("Withdrew $" + amount + ", new balance: $" + balance);
}
}
public class Main {
public static void main(String[] args) {
BankAccount account = new BankAccount(100.0);
try {
account.withdraw(50.0);
account.withdraw(100.0);
} catch (InsufficientFundsException e) {
System.out.println("Transaction failed: " + e.getMessage());
}
}
}
Output:
Withdrew $50.0, new balance: $50.0
Transaction failed: Cannot withdraw $100.0, balance is only $50.0
Because withdraw declares throws InsufficientFundsException, the compiler forces main to either catch it or propagate it — you cannot forget to handle an overdraft. The first withdrawal succeeds; the second exceeds the balance, so the constructor builds a message describing exactly what went wrong, and the catch block prints it via getMessage().
Example 2: An unchecked custom exception with extra data
Unchecked exceptions suit programming errors — like constructing a Person with an impossible age. Adding a field lets the catcher inspect the bad value directly instead of parsing text.
class InvalidAgeException extends RuntimeException {
private final int invalidAge;
public InvalidAgeException(String message, int invalidAge) {
super(message);
this.invalidAge = invalidAge;
}
public int getInvalidAge() {
return invalidAge;
}
}
class Person {
private String name;
private int age;
public Person(String name, int age) {
if (age < 0 || age > 150) {
throw new InvalidAgeException("Age must be between 0 and 150, got: " + age, age);
}
this.name = name;
this.age = age;
}
public String toString() {
return name + " (" + age + ")";
}
}
public class Main {
public static void main(String[] args) {
try {
Person p1 = new Person("Alice", 30);
System.out.println("Created: " + p1);
Person p2 = new Person("Bob", -5);
System.out.println("Created: " + p2);
} catch (InvalidAgeException e) {
System.out.println("Error: " + e.getMessage());
System.out.println("Rejected age value: " + e.getInvalidAge());
}
}
}
Output:
Created: Alice (30)
Error: Age must be between 0 and 150, got: -5
Rejected age value: -5
InvalidAgeException extends RuntimeException, so the Person constructor does not need a throws clause, and callers are not forced to catch it — appropriate because passing an invalid age is a caller bug, not an expected recoverable condition. The extra invalidAge field lets the catch block retrieve the offending value with getInvalidAge() instead of scraping it out of the message string.
Example 3: Chaining a custom exception to its cause
Real applications often catch a low-level exception and rethrow a higher-level, more meaningful one — without losing the original cause.
class DataAccessException extends Exception {
public DataAccessException(String message, Throwable cause) {
super(message, cause);
}
}
class UserRepository {
public String findUserById(int id) throws DataAccessException {
try {
return lookupInDatabase(id);
} catch (ArithmeticException e) {
throw new DataAccessException("Failed to look up user with id " + id, e);
}
}
private String lookupInDatabase(int id) {
int result = 100 / id;
return "User#" + result;
}
}
public class Main {
public static void main(String[] args) {
UserRepository repo = new UserRepository();
try {
System.out.println(repo.findUserById(0));
} catch (DataAccessException e) {
System.out.println("Operation failed: " + e.getMessage());
System.out.println("Root cause: " + e.getCause());
}
}
}
Output:
Operation failed: Failed to look up user with id 0
Root cause: java.lang.ArithmeticException: / by zero
lookupInDatabase divides by id, and passing 0 throws ArithmeticException. Rather than letting that leak out as-is (which would tell callers nothing about which operation failed), findUserById wraps it in a DataAccessException, passing the original exception as the cause. The caller now sees a meaningful message from getMessage() and can still retrieve the original low-level exception with getCause() for logging or debugging.
Under the Hood: What the JVM Does
When you write throw new MyException(...), two things happen in sequence:
- Construction. The
newexpression allocates the exception object on the heap and runs its constructor chain up toThrowable, which calls the native methodfillInStackTrace(). This walks the current thread’s call stack and records every frame (class, method, line number) into the object. This is the most expensive part of throwing an exception — which is why exceptions should not be used for routine control flow in hot loops. - Throwing. The
throwstatement hands the constructed object to the JVM, which immediately stops normal execution and starts unwinding the call stack, frame by frame, looking for acatchblock whose declared type is the thrown object’s class or one of its superclasses. This is why a custom exception’s class hierarchy matters at runtime: acatch (Exception e)block will catch yourInsufficientFundsExceptiontoo, becauseInsufficientFundsExceptionis anExceptionthrough inheritance. If no matchingcatchexists in the current method, the JVM discards that stack frame and checks the caller, and so on, until it either finds a handler or reaches the thread’s top and prints the stack trace before terminating that thread.
Once caught, the exception object behaves like any other object: you can call your custom getters on it, log it, wrap it again, or rethrow it. The stack trace captured at construction time stays attached no matter how many methods it passes through, which is exactly why chaining with super(message, cause) is so valuable — it lets you preserve every layer’s stack trace instead of only the outermost one.
Common Mistakes
Mistake 1: Forgetting to forward the message to the superclass
If your constructor doesn’t call super(message), the compiler still compiles it fine — it silently inserts a no-argument super() call — but getMessage() will always return null.
class OrderException extends Exception {
public OrderException(String message) {
// BUG: forgot to call super(message)
}
}
public class Main {
public static void main(String[] args) {
try {
throw new OrderException("Order #4521 could not be processed");
} catch (OrderException e) {
System.out.println("Caught: " + e.getMessage());
}
}
}
Output:
Caught: null
The fix is simple: always pass the message argument to super(...).
class OrderException extends Exception {
public OrderException(String message) {
super(message);
}
}
public class Main {
public static void main(String[] args) {
try {
throw new OrderException("Order #4521 could not be processed");
} catch (OrderException e) {
System.out.println("Caught: " + e.getMessage());
}
}
}
Output:
Caught: Order #4521 could not be processed
Mistake 2: Losing the original cause when wrapping an exception
It’s tempting to catch a low-level exception and throw a new one with only a message, discarding the original. This destroys the original stack trace and makes debugging production failures far harder.
class ConfigException extends RuntimeException {
public ConfigException(String message) {
super(message);
}
}
class ConfigLoader {
public void load() {
try {
int[] values = new int[2];
int x = values[5];
} catch (ArrayIndexOutOfBoundsException e) {
throw new ConfigException("Failed to load configuration");
}
}
}
public class Main {
public static void main(String[] args) {
try {
new ConfigLoader().load();
} catch (ConfigException e) {
System.out.println("Message: " + e.getMessage());
System.out.println("Cause: " + e.getCause());
}
}
}
Output:
Message: Failed to load configuration
Cause: null
Always pass the caught exception as the cause so it’s preserved and reachable via getCause():
class ConfigException extends RuntimeException {
public ConfigException(String message, Throwable cause) {
super(message, cause);
}
}
class ConfigLoader {
public void load() {
try {
int[] values = new int[2];
int x = values[5];
} catch (ArrayIndexOutOfBoundsException e) {
throw new ConfigException("Failed to load configuration", e);
}
}
}
public class Main {
public static void main(String[] args) {
try {
new ConfigLoader().load();
} catch (ConfigException e) {
System.out.println("Message: " + e.getMessage());
System.out.println("Cause: " + e.getCause());
}
}
}
Output:
Message: Failed to load configuration
Cause: java.lang.ArrayIndexOutOfBoundsException: Index 5 out of bounds for length 2
Best Practices
- Name custom exception classes ending in
Exception(e.g.OrderNotFoundException) so their purpose is obvious at a glance. - Extend
RuntimeExceptionfor programmer errors and invalid arguments; extendExceptiononly when you want the compiler to force callers to handle a recoverable condition. - Always provide a constructor that accepts both a message and a cause (
super(message, cause)) so the exception can be chained without losing information. - Never swallow an exception silently in a
catchblock — at minimum log it, and prefer wrapping and rethrowing with context. - Keep exception hierarchies shallow — one base exception per subsystem (e.g.
PaymentException) with a few specific subclasses, rather than dozens of unrelated one-off classes. - Don’t put business logic inside an exception class — it should be a data carrier, not an actor.
- Avoid using exceptions for expected, frequent control flow (like end-of-input); reserve them for genuinely exceptional situations, since stack-trace capture has real cost.
- Document checked exceptions with a Javadoc
@throwstag so callers understand when and why they occur.
Practice Exercises
- Exercise 1: Write a checked exception
InvalidPasswordExceptionthat is thrown when a password is shorter than 8 characters. Write a methodregister(String password)that throws it, and amainthat catches it and prints the message. - Exercise 2: Write an unchecked exception
NegativeQuantityExceptionwith anint quantityfield, thrown from aCart.addItem(int quantity)method whenquantityis negative. Catch it and print both the message and the rejected quantity. - Exercise 3: Write a custom exception
FileParseExceptionthat wraps aNumberFormatException(thrown byInteger.parseInton bad input) using exception chaining. Print both the wrapper’s message andgetCause()in the catch block.
Summary
- A custom exception is a class extending
Exception(checked) orRuntimeException(unchecked) to represent a specific, meaningful failure. - Checked exceptions force callers to handle them via
catchorthrows; unchecked exceptions do not. - Always forward the message (and cause, when wrapping another exception) to the superclass constructor with
super(...). - The stack trace is captured when the exception object is constructed, not when it is thrown.
- Custom fields let a
catchblock react programmatically instead of parsing a message string. - Chaining with
super(message, cause)preserves the original exception for debugging — never discard it silently.
