Java Exception Handling Tutorial: try-catch-finally, try-with-resources and Custom Exceptions

Sooner or later every Java program meets bad input, a missing file or a network that is down. If you do not plan for it, your program crashes with a long red stack trace, and in a capstone defense that usually happens at the worst possible moment. Exception handling is how Java lets you catch those problems, react to them, and keep the program running or fail with a clear message.

In this Java exception handling tutorial you will learn try, catch and finally, how try-with-resources closes files for you, the difference between checked and unchecked exceptions, how to create custom exceptions with throw and throws, multi-catch, exception chaining, and how to read a stack trace. Every program below was compiled and run on JDK 21, and the output is copied from the terminal.

Requirements

  • JDK 11 or newer (JDK 17 or 21 recommended). Check with java -version.
  • Any editor or IDE: IntelliJ IDEA, VS Code with the Java extensions, NetBeans or Eclipse.
  • Basic Java: classes, methods, loops and arrays.

Since JDK 11 you can run a single source file directly with java FileName.java, without a separate javac step. All example files are in the zip at the bottom of this page.

What Is an Exception?

An exception is an object that Java creates when something goes wrong while the program is running. All exceptions extend Throwable, and the family tree looks like this:

  • Error: serious problems in the JVM itself, such as OutOfMemoryError. You normally do not catch these.
  • Exception: problems your program can handle.
    • Checked exceptions, such as IOException and SQLException. The compiler forces you to catch them or declare them with throws. They usually come from the outside world: files, databases, networks.
    • Unchecked exceptions (subclasses of RuntimeException), such as NullPointerException, ArithmeticException, NumberFormatException and IllegalArgumentException. The compiler does not force you to handle them. They usually mean a bug or invalid input.

Step 1: try, catch and finally

Put the code that might fail inside try. Each catch block handles one type of exception. The finally block runs no matter what. Save this as TryCatchBasics.java and run java TryCatchBasics.java:

  1. import java.util.List;
  2.  
  3. public class TryCatchBasics {
  4.     public static void main(String[] args) {
  5.         List<String> inputs = List.of("25", "abc", "0", "");
  6.  
  7.         for (String input : inputs) {
  8.             System.out.println("Input: \"" + input + "\"");
  9.             try {
  10.                 int number = Integer.parseInt(input);   // may throw NumberFormatException
  11.                 int result = 100 / number;              // may throw ArithmeticException
  12.                 System.out.println("  100 / " + number + " = " + result);
  13.             } catch (NumberFormatException e) {
  14.                 System.out.println("  Not a valid number: " + e.getMessage());
  15.             } catch (ArithmeticException e) {
  16.                 System.out.println("  Math error: " + e.getMessage());
  17.             } finally {
  18.                 System.out.println("  (finally always runs)");
  19.             }
  20.         }
  21.         System.out.println("Program finished normally.");
  22.     }
  23. }

Output:

Input: "25"
  100 / 25 = 4
  (finally always runs)
Input: "abc"
  Not a valid number: For input string: "abc"
  (finally always runs)
Input: "0"
  Math error: / by zero
  (finally always runs)
Input: ""
  Not a valid number: For input string: ""
  (finally always runs)
Program finished normally.

Notice that the loop never stops. The bad inputs "abc" and "" throw NumberFormatException, and "0" throws ArithmeticException on the division, but each one is caught and the program moves on to the next input. Without the try block, the first bad value would have crashed the whole program.

When you have several catch blocks, Java checks them from top to bottom and uses the first one that matches. So always put specific exceptions first and general ones like Exception last; the compiler will actually refuse to compile a catch that can never be reached.

Step 2: try-with-resources Closes Things for You

Files, database connections and network sockets must be closed after use, even when an error happens. The old way was a finally block with its own nested try. Since Java 7, try-with-resources does it automatically for any object that implements AutoCloseable. Save this as TryWithResources.java:

  1. import java.io.BufferedReader;
  2. import java.io.IOException;
  3. import java.nio.file.Files;
  4. import java.nio.file.NoSuchFileException;
  5. import java.nio.file.Path;
  6.  
  7. public class TryWithResources {
  8.  
  9.     // A tiny resource so we can see exactly when close() is called
  10.     static class Connection implements AutoCloseable {
  11.         private final String name;
  12.         Connection(String name) { this.name = name; System.out.println("open  " + name); }
  13.         void query(String sql) { System.out.println("query " + name + ": " + sql); }
  14.         @Override public void close() { System.out.println("close " + name); }
  15.     }
  16.  
  17.     static int countLines(Path file) throws IOException {
  18.         int lines = 0;
  19.         try (BufferedReader reader = Files.newBufferedReader(file)) {
  20.             while (reader.readLine() != null) {
  21.                 lines++;
  22.             }
  23.         } // reader.close() is called here automatically, even if readLine() throws
  24.         return lines;
  25.     }
  26.  
  27.     public static void main(String[] args) throws IOException {
  28.         Path file = Path.of("students.txt");
  29.         Files.writeString(file, "Ana\nBen\nCarlo\n");
  30.  
  31.         System.out.println("Lines in students.txt: " + countLines(file));
  32.  
  33.         try {
  34.             countLines(Path.of("missing.txt"));
  35.         } catch (NoSuchFileException e) {
  36.             System.out.println("File not found: " + e.getMessage());
  37.         }
  38.  
  39.         System.out.println("--- two resources ---");
  40.         try (Connection db = new Connection("db"); Connection cache = new Connection("cache")) {
  41.             db.query("SELECT 1");
  42.             throw new IllegalStateException("something failed inside the block");
  43.         } catch (IllegalStateException e) {
  44.             System.out.println("caught: " + e.getMessage());
  45.         }
  46.         Files.delete(file);
  47.     }
  48. }

Output:

Lines in students.txt: 3
File not found: missing.txt
--- two resources ---
open  db
open  cache
query db: SELECT 1
close cache
close db
caught: something failed inside the block

Three things to learn from this output:

  • countLines() declares throws IOException instead of catching it. That passes the decision to the caller, which is often the right choice for utility methods.
  • Reading a missing file throws NoSuchFileException, a subclass of IOException, so we can catch it specifically and print a friendly message.
  • With two resources, Java closes them in reverse order (cache first, then db), and it closes them before the catch block runs. Your connections are released even though the block threw an exception.

Rule of thumb: whenever you open something that has a close() method, such as a Scanner on a file, a JDBC Connection, PreparedStatement or ResultSet, open it in a try-with-resources statement.

Step 3: Custom Exceptions with throw and throws

Java's built-in exceptions do not know your business rules. For those, create your own exception class. Extend Exception for a checked exception (the caller must deal with it) or RuntimeException for an unchecked one. Save this as CustomExceptions.java:

  1. public class CustomExceptions {
  2.  
  3.     // Checked exception: the caller MUST handle or declare it
  4.     static class InsufficientBalanceException extends Exception {
  5.         private final double shortBy;
  6.  
  7.         InsufficientBalanceException(double balance, double amount) {
  8.             super(String.format("Cannot withdraw %.2f; balance is only %.2f", amount, balance));
  9.             this.shortBy = amount - balance;
  10.         }
  11.  
  12.         double getShortBy() { return shortBy; }
  13.     }
  14.  
  15.     static class BankAccount {
  16.         private final String owner;
  17.         private double balance;
  18.  
  19.         BankAccount(String owner, double balance) {
  20.             this.owner = owner;
  21.             this.balance = balance;
  22.         }
  23.  
  24.         void withdraw(double amount) throws InsufficientBalanceException {
  25.             if (amount <= 0) {
  26.                 // Unchecked: a programming error, the caller passed a bad value
  27.                 throw new IllegalArgumentException("Amount must be positive: " + amount);
  28.             }
  29.             if (amount > balance) {
  30.                 throw new InsufficientBalanceException(balance, amount);
  31.             }
  32.             balance -= amount;
  33.             System.out.printf("%s withdrew %.2f, new balance %.2f%n", owner, amount, balance);
  34.         }
  35.     }
  36.  
  37.     public static void main(String[] args) {
  38.         BankAccount account = new BankAccount("Maria", 1500.00);
  39.         double[] withdrawals = {500.00, 2000.00, -50.00, 1000.00};
  40.  
  41.         for (double amount : withdrawals) {
  42.             try {
  43.                 account.withdraw(amount);
  44.             } catch (InsufficientBalanceException e) {
  45.                 System.out.println("Declined: " + e.getMessage());
  46.                 System.out.printf("  You are short by %.2f%n", e.getShortBy());
  47.             } catch (IllegalArgumentException e) {
  48.                 System.out.println("Invalid input: " + e.getMessage());
  49.             }
  50.         }
  51.     }
  52. }

Output:

Maria withdrew 500.00, new balance 1000.00
Declined: Cannot withdraw 2000.00; balance is only 1000.00
  You are short by 1000.00
Invalid input: Amount must be positive: -50.0
Maria withdrew 1000.00, new balance 0.00

Here is what is going on:

  • throw (singular) actually creates the problem: throw new InsufficientBalanceException(...).
  • throws (with an s) goes in the method signature and warns callers: "this method can throw this checked exception." If you remove the catch (InsufficientBalanceException e) block in main, the code will not compile.
  • A custom exception can carry extra data. Here getShortBy() tells the user exactly how much money is missing.
  • A negative amount is a programming mistake, not a normal business situation, so it throws the unchecked IllegalArgumentException. This split is a good guideline: checked for situations the caller can reasonably recover from, unchecked for bugs and invalid arguments.

Step 4: Multi-catch and Exception Chaining

When two exceptions need the same handling, you can catch both in one block with |. And when you convert a low-level exception into one that makes sense for your app, pass the original as the cause so the real reason is not lost. Save this as ChainingAndMultiCatch.java:

  1. import java.io.IOException;
  2. import java.nio.file.Files;
  3. import java.nio.file.Path;
  4. import java.util.List;
  5.  
  6. public class ChainingAndMultiCatch {
  7.  
  8.     // Unchecked custom exception that keeps the original error as its "cause"
  9.     static class ConfigException extends RuntimeException {
  10.         ConfigException(String message) { super(message); }
  11.         ConfigException(String message, Throwable cause) { super(message, cause); }
  12.     }
  13.  
  14.     static int readPort(Path file) {
  15.         try {
  16.             int port = Integer.parseInt(Files.readString(file).trim());
  17.             if (port < 1 || port > 65535) {
  18.                 throw new ConfigException("Port out of range in " + file + ": " + port);
  19.             }
  20.             return port;
  21.         } catch (IOException | NumberFormatException e) {   // multi-catch
  22.             throw new ConfigException("Cannot read port from " + file, e);
  23.         }
  24.     }
  25.  
  26.     public static void main(String[] args) throws IOException {
  27.         Files.writeString(Path.of("port-good.txt"), " 8080 \n");
  28.         Files.writeString(Path.of("port-typo.txt"), "80a0\n");
  29.         Files.writeString(Path.of("port-range.txt"), "70000\n");
  30.  
  31.         List<String> files = List.of("port-good.txt", "port-typo.txt", "port-missing.txt", "port-range.txt");
  32.         for (String name : files) {
  33.             try {
  34.                 System.out.println("Port: " + readPort(Path.of(name)));
  35.             } catch (ConfigException e) {
  36.                 System.out.println(e.getMessage());
  37.                 if (e.getCause() != null) {
  38.                     System.out.println("  cause: " + e.getCause());
  39.                 }
  40.             }
  41.         }
  42.  
  43.         for (String name : List.of("port-good.txt", "port-typo.txt", "port-range.txt")) {
  44.             Files.delete(Path.of(name));
  45.         }
  46.     }
  47. }

Output:

Port: 8080
Cannot read port from port-typo.txt
  cause: java.lang.NumberFormatException: For input string: "80a0"
Cannot read port from port-missing.txt
  cause: java.nio.file.NoSuchFileException: port-missing.txt
Port out of range in port-range.txt: 70000

The calling code only needs to know about ConfigException, but nothing is hidden: getCause() still shows whether the problem was a typo (NumberFormatException) or a missing file (NoSuchFileException). When such an exception is printed with a stack trace, Java shows a Caused by: section for the original error, which makes debugging much easier. Never write throw new ConfigException("Invalid port") inside a catch without passing e; you throw away the most useful information.

Step 5: Read a Stack Trace

When an exception is not caught, Java prints a stack trace and stops. Learning to read it is one of the fastest ways to fix bugs. Save this as StackTraceDemo.java:

  1. public class StackTraceDemo {
  2.     static double average(int[] scores) {
  3.         int count = scores.length;
  4.         int total = 0;
  5.         for (int score : scores) total += score;
  6.         return (double) total / count;
  7.     }
  8.  
  9.     static String report(int[] scores) {
  10.         return String.format("Average: %.2f", average(scores));
  11.     }
  12.  
  13.     public static void main(String[] args) {
  14.         System.out.println(report(new int[]{88, 92, 79}));
  15.         System.out.println(report(null));   // oops
  16.     }
  17. }

Output (compiled with javac -g StackTraceDemo.java, then run with java StackTraceDemo):

Average: 86.33
Exception in thread "main" java.lang.NullPointerException: Cannot read the array length because "scores" is null
	at StackTraceDemo.average(StackTraceDemo.java:3)
	at StackTraceDemo.report(StackTraceDemo.java:10)
	at StackTraceDemo.main(StackTraceDemo.java:15)

Read it from the top:

  1. The first line gives the exception type and a message. Since Java 15, "helpful NullPointerException" messages (on by default) tell you exactly what was null: here, the variable scores.
  2. The first at line is where it happened: line 3 of average().
  3. The lines below show how the program got there: main (line 15) called report (line 10), which called average.

So the fix belongs either in main (do not pass null) or in average() (check the argument and throw a clear IllegalArgumentException). If your message shows a placeholder like "<parameter1>" instead of the variable name, the class was compiled without debug information; compile with javac -g (IDEs usually include it already).

Exception Handling Best Practices

  • Never swallow exceptions. An empty catch (Exception e) { } hides bugs. At least log the error, or rethrow it.
  • Catch the most specific type you can handle. Catching Exception everywhere also catches bugs you did not expect.
  • Do not use exceptions for normal flow. Check if (list.isEmpty()) instead of catching an index error.
  • Validate early. Throw IllegalArgumentException at the start of a method when the input is invalid, with a message that includes the bad value.
  • Use try-with-resources for every file, stream, and database object.
  • Keep the cause when wrapping exceptions.
  • Show friendly messages to users, full details to developers. In a Swing or web app, show "Could not save the record" on screen and write the stack trace to a log.

Conclusion

Exception handling turns crashes into controlled, understandable behavior. Use try/catch for code that can fail, finally or, better, try-with-resources for cleanup, checked custom exceptions for business rules the caller should handle, unchecked exceptions for bugs and bad arguments, and always keep the original cause. Download the examples below, run them, then change the inputs and watch how each catch block reacts. That is the quickest way to make these rules stick.