You already know try/catch/finally, throw, throws, and the difference between a checked and an unchecked exception. This article is the next step: defining the exception types your own code throws, so a failure arrives named after the thing that failed instead of after the library that happened to notice.
Writing the class takes four lines. Everything that makes it worth writing is the rest of this article — which supertype to pick, which constructors to provide, what to hang on the object, how to wrap the original failure without erasing it, and the cases where the JDK already has the type you were about to invent.
![]()
Every stack trace, program output and compiler message below was produced by compiling and running the code on OpenJDK 21.0.6. The javac errors are quoted verbatim, including their caret lines.
Why write your own exception type
A custom exception earns its place when it does at least one of three things that a JDK type cannot:
- It names the failure in your domain's vocabulary.
InsufficientStockExceptiontells a reader what went wrong.IllegalStateExceptiontells them only that something was in the wrong state. - It gives callers something specific to catch. One
catchclause that means exactly one thing, and cannot accidentally swallow an unrelated failure that happens to share a JDK type. - It carries structured data. The SKU, the shortfall, the config key, the offending input — as fields you can read, not as a sentence buried inside a message string that the caller would have to parse back out.
If none of those apply, the last section of this article is for you: there is a good chance the JDK already ships the type you want.
The smallest custom exception class
An exception class is an ordinary class. The only requirement is that it descends from java.lang.Throwable, and in practice you extend Exception or RuntimeException — never Throwable or Error directly.
public class OrderException extends Exception {
public OrderException(String message) {
super(message);
}
}
That is the entire class. There is no interface to implement, no annotation to add, no registry to update.
The constructor is the part people get wrong, because constructors are not inherited. Exception has four of them; your class has exactly the ones you declare. Write only the one above, and any call site that wants to pass a cause fails at compile time:
throw new OrderException("order #5 not found", new IllegalStateException("db down"));
NoCtor.java:9: error: constructor OrderException in class OrderException cannot be applied to given types;
throw new OrderException("order #5 not found", new IllegalStateException("db down"));
^
required: String
found: String,IllegalStateException
reason: actual and formal argument lists differ in length
1 error
The same rule bites one level down. If you subclass your own exception and the superclass has no no-argument constructor, the compiler's implicit super() has nothing to call, and the error lands on the class declaration line rather than on any constructor you wrote:
class BaseEx extends Exception {
BaseEx(String message) {
super(message);
}
}
class ChildEx extends BaseEx {
}
SubNoSuper.java:7: error: constructor BaseEx in class BaseEx cannot be applied to given types;
class ChildEx extends BaseEx {
^
required: String
found: no arguments
reason: actual and formal argument lists differ in length
1 error
One more detail worth knowing before you commit the file. Throwable implements Serializable, so every exception class you write is a serializable class, and javac will say so if you ask it to:
Lint.java:1: warning: [serial] serializable class OrderException has no definition of serialVersionUID
class OrderException extends Exception {
^
1 warning
It is a warning, not an error, and the default build is silent about it. Add private static final long serialVersionUID = 1L; if your project builds with -Xlint:serial or -Werror, or if the exception genuinely crosses a serialization boundary. Otherwise it is noise.
extends Exception or extends RuntimeException?
This is the only decision in the class that the caller can feel, and it is made by one word.

Here is the same program twice. The only difference is the supertype of OrderException:
class OrderException extends Exception {
OrderException(String message) {
super(message);
}
}
public class Orders {
static void loadOrder(int id) throws OrderException {
throw new OrderException("order #" + id + " not found");
}
public static void main(String[] args) {
loadOrder(5);
}
}
With extends Exception, that does not compile at all:
Orders.java:13: error: unreported exception OrderException; must be caught or declared to be thrown
loadOrder(5);
^
1 error
Change the supertype to RuntimeException, delete the now-unnecessary throws clause, and the same file compiles without a word and fails at run time instead:
Exception in thread "main" OrderException: order #5 not found
at Orders.loadOrder(Orders.java:9)
at Orders.main(Orders.java:13)
That is the whole difference, and it is bigger than it looks:
extends Exception (checked) | extends RuntimeException (unchecked) | |
|---|---|---|
| Compiler enforcement | every caller must catch it or declare it | none |
| Appears in your method signature | yes, via throws | no |
| Propagates through a caller who forgot | impossible — it will not compile | silently, all the way to the top |
| Cost of adding one later | recompiles every caller in the chain | none |
Works in a lambda passed to Stream.map | no, the functional interface does not declare it | yes |
| Typical use | a failure the caller has a real alternative for | a bug, or a failure nobody can recover from |
The rule that holds up in practice: make it checked when a reasonable caller has something better to do than crash. A missing order can be retried, reported, or answered with a 404; forcing the caller to acknowledge it is a service. A malformed internal identifier is a bug in your code, and making every caller write catch for a bug just produces empty catch blocks.
Two consequences people discover late. A checked exception cannot be thrown out of a lambda passed to map, forEach or any other standard functional interface, because those interfaces do not declare it — that alone pushes many library authors to unchecked. And widening a method from unchecked to checked is a source-breaking change for everyone who calls it, while the reverse is free.
What each constructor leaves in the object
Throwable defines four public-facing constructors, and the convention is to mirror all four in your class, each one delegating straight to super:
public class OrderException extends Exception {
public OrderException() {
super();
}
public OrderException(String message) {
super(message);
}
public OrderException(String message, Throwable cause) {
super(message, cause);
}
public OrderException(Throwable cause) {
super(cause);
}
}
They are not interchangeable. Constructing one of each and printing what came out:
new OrderException()
getMessage() = null
getCause() = null
toString() = OrderException
new OrderException(msg)
getMessage() = order #5 not found
getCause() = null
toString() = OrderException: order #5 not found
new OrderException(msg, cause)
getMessage() = order #5 not found
getCause() = java.lang.IllegalStateException: connection pool exhausted
toString() = OrderException: order #5 not found
new OrderException(cause)
getMessage() = java.lang.IllegalStateException: connection pool exhausted
getCause() = java.lang.IllegalStateException: connection pool exhausted
toString() = OrderException: java.lang.IllegalStateException: connection pool exhausted
| Constructor | getMessage() | getCause() |
|---|---|---|
() | null | null |
(String message) | what you passed | null |
(String message, Throwable cause) | what you passed | what you passed |
(Throwable cause) | cause.toString() | what you passed |
The last row is the one to remember. The cause-only constructor does not leave the message empty — it derives it from cause.toString(), which is why that run printed the fully qualified java.lang.IllegalStateException: connection pool exhausted as the message of an OrderException. That is occasionally what you want and usually not: it hands the caller a message written in the vocabulary of the layer you were trying to hide.
Writing all four is a convention, not a rule. Write the ones your code actually throws — but write (String, Throwable) in every exception class that will ever wrap another, because it is the one that keeps the evidence, and adding it later means touching every throw site.
Carrying structured data instead of formatting a string
The moment an exception message contains a value the caller might want, that value should be a field. A message is for humans reading a log; a field is for code deciding what to do next.
class InsufficientStockException extends Exception {
private final String sku;
private final int requested;
private final int available;
InsufficientStockException(String sku, int requested, int available) {
super("cannot ship " + requested + " x " + sku + ": only " + available + " in stock");
this.sku = sku;
this.requested = requested;
this.available = available;
}
public String getSku() {
return sku;
}
public int getAvailable() {
return available;
}
public int getShortfall() {
return requested - available;
}
}
The caller then has something to work with instead of a sentence to regex:
try {
ship("KB-100", 5, 2);
} catch (InsufficientStockException e) {
System.out.println("caught : " + e.getMessage());
System.out.println("sku : " + e.getSku());
System.out.println("short by: " + e.getShortfall());
System.out.println("action : back-order " + e.getShortfall()
+ ", ship " + e.getAvailable() + " now");
}
caught : cannot ship 5 x KB-100: only 2 in stock
sku : KB-100
short by: 3
action : back-order 3, ship 2 now
Four habits make this work:
- Build the message in the constructor, from the same values you store. One place decides the wording, so every throw site is phrased identically and cannot drift.
- Make the fields
final. An exception is already in flight by the time anyone sees it; nothing should be able to change what it says happened. - Add derived accessors like
getShortfall()when they are what callers actually want. It is a normal class. - Do not override
getMessage()to splice fields in.getMessage()is called bytoString(), byprintStackTrace()and by every logging framework, sometimes on a partly constructed object during deserialization. Compose the string once, in the constructor.
One exception class with three fields beats three exception classes with none. The moment you find yourself writing OrderNotFoundException, OrderExpiredException and OrderLockedException that no caller distinguishes, collapse them into one class with a reason field or a small enum.
Chaining: the cause, getCause() and the Caused by section
Wrapping means throwing your type while keeping the original inside it. The cause is a plain reference stored on Throwable and read back with getCause() — nothing more exotic than that. What makes it valuable is that printStackTrace() walks the chain and prints every link.

Here is a three-link chain: an array access fails, storage wraps it, the order layer wraps that.
class StorageException extends Exception {
StorageException(String message, Throwable cause) {
super(message, cause);
}
}
class OrderException extends Exception {
OrderException(String message, Throwable cause) {
super(message, cause);
}
}
public class DeepChain {
static final String[] ROWS = { "1,keyboard", "2,mouse" };
static String readRow(int index) throws StorageException {
try {
return ROWS[index];
} catch (ArrayIndexOutOfBoundsException e) {
throw new StorageException("no row at index " + index, e);
}
}
static String loadOrder(int id) throws OrderException {
try {
return readRow(id);
} catch (StorageException e) {
throw new OrderException("order #" + id + " could not be loaded", e);
}
}
public static void main(String[] args) throws Exception {
System.out.println(loadOrder(5));
}
}
Running it prints all three links, outermost first:
Exception in thread "main" OrderException: order #5 could not be loaded
at DeepChain.loadOrder(DeepChain.java:28)
at DeepChain.main(DeepChain.java:33)
Caused by: StorageException: no row at index 5
at DeepChain.readRow(DeepChain.java:20)
at DeepChain.loadOrder(DeepChain.java:26)
... 1 more
Caused by: java.lang.ArrayIndexOutOfBoundsException: Index 5 out of bounds for length 2
at DeepChain.readRow(DeepChain.java:18)
... 2 more
Read it from the bottom up and you get the story in causal order: index 5 was out of bounds on a length-2 array, so there was no row 5, so order 5 could not be loaded. Read it from the top down and you get it in layer order: the order layer failed, because storage failed, because the array access failed.
Where ... N more comes from. Each link prints only the frames it does not share with the link that wrapped it. StorageException and OrderException were both thrown while main was on the stack, so the identical trailing frame at DeepChain.main(DeepChain.java:33) is replaced by ... 1 more. The innermost link shares two trailing frames with the one above it, so it prints ... 2 more. Nothing is lost: the elided frames are exactly the ones already printed above. It is a de-duplication, not a truncation.
Line 26 versus line 28. Look closely at the two loadOrder frames. OrderException was created on line 28, at the throw inside the catch block; StorageException was already travelling through line 26, the call to readRow. A frame records where that exception was, so a wrapped chain gives you both the failure point and every layer that re-threw it.
initCause is the alternative, and it fires exactly once. If a constructor cannot take a cause — usually because you are wrapping an exception class you do not control — initCause sets it after construction. Four attempts, each run through a helper that prints -> ok or the getMessage() of whatever IllegalStateException came back:
Throwable root = new java.io.IOException("socket closed");
attempt("initCause once ", () -> {
Exception e = new IllegalStateException("wrapped");
e.initCause(root);
});
attempt("initCause twice ", () -> {
Exception e = new IllegalStateException("wrapped");
e.initCause(root);
e.initCause(root);
});
attempt("initCause after super(message, cause) ", () -> {
Exception e = new IllegalStateException("wrapped", root);
e.initCause(root);
});
attempt("initCause(null), then initCause(root) ", () -> {
Exception e = new IllegalStateException("wrapped");
e.initCause(null);
e.initCause(root);
});
initCause once -> ok
initCause twice -> Can't overwrite cause with java.io.IOException: socket closed
initCause after super(message, cause) -> Can't overwrite cause with java.io.IOException: socket closed
initCause(null), then initCause(root) -> Can't overwrite cause with java.io.IOException: socket closed
Only the first one works. The cause slot is one-shot: either the constructor fills it or initCause does, once. The second line is the obvious case; the third shows that a constructor which already took a cause counts as having filled it; and the fourth is the sharp edge — initCause(null) counts as filling the slot even though getCause() still returns null afterwards, so the exception can never be given a real cause again. Prefer the constructor whenever your class offers one; use initCause only where it does not.
Exception translation at an API boundary
Chaining is the mechanism. Translation is the reason you reach for it: at the edge of a module, an exception thrown by your implementation gets replaced by one your API declares, with the original kept as the cause.

A configuration reader that parses a port number can fail three ways. First, letting the low-level exception through:
static int readPort(String raw) {
return Integer.parseInt(raw);
}
Exception in thread "main" java.lang.NumberFormatException: For input string: "8o80"
at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67)
at java.base/java.lang.Integer.parseInt(Integer.java:662)
at java.base/java.lang.Integer.parseInt(Integer.java:778)
at Leak.readPort(Leak.java:3)
at Leak.main(Leak.java:7)
Five frames, three of them inside java.base, and a message that mentions a string but not the setting it came from. The caller now has to catch NumberFormatException — a type that leaked out of your choice to use Integer.parseInt. Switch to a hand-rolled parser and every caller breaks.
Second, wrapping but dropping the cause:
} catch (NumberFormatException e) {
throw new ConfigException("invalid value for 'server.port': " + raw);
}
Exception in thread "main" ConfigException: invalid value for 'server.port': 8o80
at LostCause.readPort(LostCause.java:12)
at LostCause.main(LostCause.java:17)
The type is right and the evidence is gone. Two frames, no Caused by: section, and no way to tell from the trace whether the parse failed, an index was out of range, or something else entirely. This is the version that turns a five-minute bug into a two-hour one.
Third, wrapping with the cause — and, because you are writing the class anyway, keeping the structured data too:
class ConfigException extends Exception {
private final String key;
private final String rawValue;
ConfigException(String key, String rawValue, Throwable cause) {
super("invalid value for '" + key + "': " + rawValue, cause);
this.key = key;
this.rawValue = rawValue;
}
public String getKey() {
return key;
}
public String getRawValue() {
return rawValue;
}
}
public class Translate {
static int readPort(String raw) throws ConfigException {
try {
return Integer.parseInt(raw);
} catch (NumberFormatException e) {
throw new ConfigException("server.port", raw, e);
}
}
}
message : invalid value for 'server.port': 8o80
key : server.port
raw : 8o80
cause : java.lang.NumberFormatException: For input string: "8o80"
--- full trace ---
ConfigException: invalid value for 'server.port': 8o80
at Translate.readPort(Translate.java:25)
at Translate.main(Translate.java:31)
Caused by: java.lang.NumberFormatException: For input string: "8o80"
at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67)
at java.base/java.lang.Integer.parseInt(Integer.java:662)
at java.base/java.lang.Integer.parseInt(Integer.java:778)
at Translate.readPort(Translate.java:23)
... 1 more
The caller catches one type it can reason about, gets getKey() and getRawValue() to build an error page with, and the full parsing failure is still there under Caused by: for whoever reads the log. Two things at once, and it costs one extra argument at the throw site.
Where to draw the line: wherever a caller outside your module would otherwise have to know what your implementation is made of. Inside a module, let exceptions travel — wrapping every call in a new type produces chains ten links deep that say nothing. At the boundary, translate. And when you translate, always pass the cause.
When not to write a custom exception
Most of the exception types people write already exist. The JDK's unchecked exceptions cover the common failure shapes, and using them means every reader already knows what they mean.
| Situation | Use this | Why not a custom type |
|---|---|---|
| A caller passed a value the method cannot accept | IllegalArgumentException | universally understood; a subclass adds a name and nothing else |
| The object is not in a state where this call makes sense | IllegalStateException | the same, and callers rarely catch these separately |
A required argument was null | NullPointerException via Objects.requireNonNull(x, "...") | the JDK already throws it with your message attached |
| There is nothing left to return | NoSuchElementException | Iterator, Optional and Scanner all throw it — reuse the vocabulary |
| This implementation does not support the operation | UnsupportedOperationException | immutable collections use it; readers recognise it instantly |
| Text could not be parsed into a number | NumberFormatException | a subclass of IllegalArgumentException, already precise |
Two of those carry no message at all by default. An empty ArrayList iterator throws NoSuchElementException with getMessage() returning null, and List.of("a").add("b") throws UnsupportedOperationException with getMessage() returning null — so when you throw them, supply a sentence, because the type alone will not.
The test is one question: would any caller catch this separately, or read data off it? If every caller would treat it the same as any other failure — log it and give up — a JDK type is enough and a custom class is a file nobody benefits from. If a caller wants to retry only this failure, or needs the SKU off it to build a back-order, write the class.
There is a middle path worth knowing: subclass a JDK exception rather than Exception. class InvalidSkuException extends IllegalArgumentException gives callers a specific type to catch while still being caught by anyone who catches IllegalArgumentException. It works well for validation, where a generic handler at the top of the stack is the normal case and one specific handler is the exception.
What a custom exception costs
Constructing a Throwable captures the stack trace. That is not free, and the cost is structural: it walks the frames that are on the stack at that moment, so it scales with how deep you are, not with a fixed charge per throw.
Measuring it structurally rather than in time. deep recurses 60 times and throws; report catches and counts what getStackTrace() returned:
static void deep(int n, int kind) {
if (n == 0) {
if (kind == 0) throw new Plain("boom");
if (kind == 1) throw new NoTrace("boom");
throw new NoFill("boom");
}
deep(n - 1, kind);
}
static void report(String label, int kind) {
try {
deep(60, kind);
} catch (RuntimeException e) {
System.out.printf("%-38s: frames = %d, message = %s%n",
label, e.getStackTrace().length, e.getMessage());
}
}
plain RuntimeException subclass : frames = 63, message = boom
writableStackTrace = false : frames = 0, message = boom
fillInStackTrace() overridden to no-op: frames = 0, message = boom
The 63 frames are the 61 deep calls plus report and main — the exact total depends on how deep you were when the throw happened, which is the whole point.
Two supported ways to opt out. The four-argument Throwable constructor, available since Java 7, is the modern one:
class Lite extends RuntimeException {
Lite(String message) {
super(message, null, false, false);
}
}
The third argument disables suppression, the fourth disables the writable stack trace. The older approach overrides fillInStackTrace() to return this without doing any work:
class NoFill extends RuntimeException {
@Override
public synchronized Throwable fillInStackTrace() {
return this;
}
}
Both produce a zero-frame trace. Both should be rare. This is a tool for an exception that is genuinely used as control flow on a hot path — a parser signalling end-of-input thousands of times a second — and the price is that when it does escape somewhere unexpected, the log tells you nothing about where it came from. For an exception that means "this request failed", the trace is the whole point. Leave it on.
Common mistakes
Writing one class per message. OrderNotFoundException, OrderExpiredException and OrderLockedException are three files, three imports and three catch clauses that all do the same thing. One class with a reason field says the same and lets a caller switch on it.
Extending Exception for a programming bug. If the only correct response is to fix the code, forcing every caller to write catch produces empty catch blocks, which are strictly worse than the crash they replace.
Extending Error or Throwable directly. Error means the JVM is in trouble — OutOfMemoryError, StackOverflowError — and catching it is almost always wrong. Extending Throwable directly gives you a checked exception that no catch (Exception e) will catch, which surprises everyone.
Catching Exception above your own type. The general catch-ordering rule bites the moment you introduce a custom class, because the clause naming it becomes unreachable and the compiler refuses to compile the file:
CatchOrder.java:11: error: exception OrderException has already been caught
} catch (OrderException e) {
^
1 error
The narrowest type first — which, once you have written one, is usually yours.
Dropping the cause. throw new ConfigException(msg) inside catch (NumberFormatException e) compiles, runs, and quietly deletes the only record of what actually failed. If the variable e is in scope and you are not passing it, that is a bug.
Building the message outside the constructor. Two throw sites, two slightly different wordings, and a log nobody can grep. Pass the values in and let the constructor phrase it.
Reflexively adding serialVersionUID — or reflexively ignoring the warning. Add it if the build treats warnings as errors or the exception really is serialized. Otherwise it is a line of ceremony per class.
FAQ
Should a custom exception extend Exception or RuntimeException?
Extend Exception when a reasonable caller has something better to do than crash — retry, fall back, return a 404 — and you want the compiler to make sure they consider it. Extend RuntimeException when the exception signals a bug, or when no caller in the chain can do anything useful about it. Two practical tie-breakers: a checked exception cannot escape a lambda passed to map or forEach, and changing a method from unchecked to checked breaks every caller that already compiles.
Do I have to write all four Throwable constructors?
No. Constructors are not inherited, so you get exactly the ones you declare, and you should declare the ones your code throws. The one always worth writing is (String message, Throwable cause), because it is the one that preserves the original failure — adding it later means editing every throw site. The cause-only (Throwable) constructor is the one to think twice about: it derives the message from cause.toString(), which pushes the lower layer's vocabulary straight into your exception's message.
What is the difference between the cause constructor and initCause?
They set the same field. super(message, cause) sets it during construction; initCause(cause) sets it afterwards, and only once — a second call throws IllegalStateException: Can't overwrite cause with ..., and so does calling it on an object that already got a cause from its constructor. Use initCause only when you are wrapping an exception class you do not control and whose constructors take no cause.
Why does my stack trace end with "... 2 more"?
Because those frames were already printed. When printStackTrace() walks a chain, each link prints only the frames it does not share with the link that wrapped it, and the identical trailing frames are collapsed into a single ... N more line. Nothing is hidden — the elided frames are exactly the ones listed above in the enclosing link. Read the Caused by: sections from the bottom up to get the failure in causal order.
Does a custom exception need serialVersionUID?
Only if it is actually serialized or your build fails on warnings. Throwable implements Serializable, so every exception class inherits that, and javac -Xlint:serial reports serializable class OrderException has no definition of serialVersionUID. The default compile is silent. Add private static final long serialVersionUID = 1L; when the warning matters to your project.
When is a custom exception overkill?
When no caller would catch it separately and no caller would read data off it. If the honest answer is "everyone just logs it", IllegalArgumentException, IllegalStateException, NoSuchElementException or UnsupportedOperationException says it with a name every Java developer already knows. Note that the last two carry a null message by default, so pass one when you throw them yourself.
Conclusion
A custom exception class is four lines of code and two real decisions. The supertype decides who is forced to deal with it: extends Exception will not let loadOrder(5); compile without a catch or a throws, while extends RuntimeException compiles silently and fails at run time. The constructors decide what the object carries, and (String, Throwable) is the one that matters, because it is the difference between a trace that ends at your wrapper and one that shows Caused by: java.lang.NumberFormatException: For input string: "8o80" four frames further down.
Everything else follows from treating it as an ordinary class. Store the values that caused the failure as final fields and build the message from them in the constructor, so callers get getShortfall() instead of a sentence to parse. Translate at module boundaries and pass the cause every time, so the chain stays intact and ... N more collapses only what has already been printed. And before writing the class at all, check whether IllegalArgumentException already says exactly what you meant — the best custom exception is often the one you did not need.
Next in this series: ArrayList and LinkedList. Arrays have a fixed length decided at creation; collections do not. The next article covers the List interface and its two workhorse implementations, how each one is actually laid out in memory, and why the choice between them changes the cost of insertion, removal and random access.