Exceptions in Java — exercises with a converter, dates and a bank (and the try I put in the wrong place)
I had to add up the grades of a group of 30 students that came in as text. Since I already knew some could be badly written, I put the loop inside a try and caught the NumberFormatException. The program didn’t crash, it printed “Grade skipped” once… and the sum came out way too low. It took me a while to see it: the try was outside the loop. As soon as the third grade failed, the rest of the try was skipped, and the rest of the try was the entire loop. The other 27 grades were never even looked at.
In these exercises you’ll decide where each try goes, which exception to throw in each case, how to translate a technical error into one people understand, and how to design your own family of exceptions. Before the solutions, three tricky snippets (the first one is exactly my misplaced try). If you need a refresher first, there’s the theory and the practice for this topic.
Table of Contents
How to use this article
- Read the task and the expected output.
- Try it for at least 15 minutes before opening the hint.
- Before writing any code, decide for each error: do I throw it or does Java? Do I handle it here or let it go up?
- Compare with the solution and read the reasoning behind each decision.
We don’t use Scanner so that the outputs are exact: the “user inputs” are in arrays. The logic is the same as if you read them from the keyboard.
Level 1 — Basic: a safe converter
Task. Create the Converter class with two static methods:
int toInt(String text, int defaultValue): converts the text (trimming spaces) to an integer; if it can’t, returnsdefaultValue.int askBetween(String[] inputs, int min, int max): simulates asking the user for a number. It goes through the inputs in order; for each one that isn’t an integer or is outside[min, max], it prints the reason and moves on to the next. It returns the first valid one. If none is valid, it throws anIllegalStateExceptionwith the number of attempts.
Tests and expected output:
System.out.println(toInt("42", 0) + " " + toInt(" 7 ", 0) + " " + toInt("3.5", -1) + " " + toInt("", -1));
System.out.println("Age:");
int age = askBetween(new String[]{"twenty", "-3", "150", " 19 "}, 0, 120);
System.out.println("Grade:");
try {
askBetween(new String[]{"ten", "11"}, 0, 10);
} catch (IllegalStateException e) {
System.out.println(" Error: " + e.getMessage());
}
System.out.println("Age saved: " + age);
42 7 -1 -1 Age: "twenty": not a whole number "-3": out of range [0, 120] "150": out of range [0, 120] Accepted 19 on attempt 4 Grade: "ten": not a whole number "11": out of range [0, 10] Error: no valid input in 2 attempts Age saved: 19
In askBetween, the try goes inside the for. The range doesn’t throw any exception on its own: check it with an if and use continue to move on to the next attempt.
Level 2 — Intermediate: a Date that can’t be created wrong
Task. Create the Date class (day, month and year, private final):
- The constructor throws
IllegalArgumentExceptionif the month isn’t between 1 and 12 or if the day doesn’t exist in that month, with a message explaining why (include how many days the month has). static boolean isLeapYear(int year): divisible by 4 and not by 100, or divisible by 400.static int daysInMonth(int month, int year).static Date parse(String text)for texts indd/mm/yyyy. If there aren’t three parts, throwIllegalArgumentException; if any part isn’t a number, catch theNumberFormatExceptionand throw anIllegalArgumentExceptioninstead, with the original as its cause.toString()in the format12/10/2026.
Tests and expected output:
String[] texts = {"12/10/2026", "29/02/2024", "29/02/2026", "31/04/2026", "15/13/2026", "1-1-2026", "aa/10/2026"};
for (String t : texts) {
try {
System.out.println("OK " + Date.parse(t));
} catch (IllegalArgumentException e) {
System.out.println("ERR " + e.getMessage());
if (e.getCause() != null) {
System.out.println(" cause: " + e.getCause().getClass().getSimpleName());
}
}
}
OK 12/10/2026
OK 29/02/2024
ERR invalid day: 29 (month 2 of 2026 has 28 days)
ERR invalid day: 31 (month 4 of 2026 has 30 days)
ERR invalid month: 13
ERR invalid format: "1-1-2026" (use dd/mm/yyyy)
ERR invalid format: "aa/10/2026" (numbers only)
cause: NumberFormatException
Exceptions have a constructor IllegalArgumentException(String message, Throwable cause). By passing the original exception as the second argument, whoever catches the new one can look it up with getCause().
Level 3 — Challenge: a bank with its own family of exceptions
Task. Design a bank’s error hierarchy and use it in transfers:
BankException extends Exception(checked): the parent of every bank problem.AccountNotFoundExceptionandInsufficientFundsException, both children ofBankException. The second one stores how much is missing (getShortBy()).Accountwith an IBAN and a balance.Bankwith an array of accounts and a counter:open(iban, balance),find(iban)(throwsAccountNotFoundException) andtransfer(from, to, amount) throws BankException.transfermust be atomic: if anything fails, not a single cent moves. Non-positive amounts and transfers to the same account are rejected withIllegalArgumentException.- In
main, if the problem is the balance, suggest the amount that could be transferred. At the end, check that the bank’s total money hasn’t changed.
Tests and expected output:
Bank bank = new Bank(3);
bank.open("ES01", 500);
bank.open("ES02", 100);
bank.open("ES03", 0);
double before = bank.total();
String[][] orders = {
{"ES01", "ES02", "200"},
{"ES02", "ES99", "50"},
{"ES02", "ES01", "400"},
{"ES02", "ES02", "10"},
{"ES01", "ES03", "-5"},
{"ES02", "ES03", "300"}
};
OK ES01 -> ES02 (€200) ERR ES02 -> ES99 (€50): account ES99 does not exist ERR ES02 -> ES01 (€400): insufficient funds in ES02: short by €100.00. Transfer €300.00 instead? ERR ES02 -> ES02 (€10): source and destination are the same account ERR ES01 -> ES03 (€-5): invalid amount: -5.0 OK ES02 -> ES03 (€300) Successful: 2 of 6 ES01: € 300.00 ES02: € 0.00 ES03: € 300.00 Total money before: €600.00 · after: €600.00
To make the transfer atomic, do all the checks (find both accounts, look at the balance) before touching any balance. And in main, the InsufficientFundsException catch has to go before the BankException one.
What’s wrong here?
Three snippets that compile. Before reading the explanation, try to predict what they print.
Snippet 1 — The misplaced try
String[] grades = {"8", "6", "seven", "9", "5"};
int sum = 0;
try {
for (String g : grades) {
sum += Integer.parseInt(g);
}
} catch (NumberFormatException e) {
System.out.println("Grade skipped: " + e.getMessage());
}
System.out.println("Sum: " + sum);
It prints Grade skipped: For input string: "seven" and Sum: 14, when the valid grades add up to 28. It’s my story from the start. When "seven" fails, **the rest of the try** is skipped, and that includes the rest of the loop: the 9 and the 5 are never added. The message says “skipped” once, but three grades were actually skipped. The fix is to move the try inside the for:
for (String g : grades) {
try {
sum += Integer.parseInt(g);
} catch (NumberFormatException e) {
System.out.println("Grade skipped: " + e.getMessage());
}
}
Ask yourself “if this fails, what do I want not to run?”. That, and only that, goes inside the try.
Snippet 2 — The finally that changes the value (or not)
static int attempts() {
int x = 1;
try {
return x;
} finally {
x = 2;
}
}
System.out.println(attempts());
It prints 1. The finally does run, and x becomes 2. But the value being returned had already been worked out and stored when return x was reached, before entering the finally. Changing x afterwards doesn’t change what’s already “on its way out”.
“fixing” it by putting return 2; inside the finally. That does change the result, but it also swallows any exception coming from the try: the finally‘s return always wins. Never put a return in a finally; use it only to clean up.
Snippet 3 — The catch that does nothing
static double readGrade(String t) {
double grade = 0;
try {
grade = Double.parseDouble(t);
} catch (NumberFormatException e) {
}
return grade;
}
double average = (readGrade("8") + readGrade("6,5") + readGrade("9")) / 3;
System.out.printf("Average: %.2f%n", average);
It prints Average: 5.67, when it should be 7.83. "6,5" can’t be converted (it has a comma), the empty catch swallows the error and readGrade returns the initial 0 as if it were a grade. Nothing in red, nothing on the console: a false result that looks true.
There are two sensible fixes: accept the comma (t.replace(',', '.')) if that’s a valid format for you, or don’t catch the exception and let it go up to someone who knows what to do. What’s never OK is an empty catch.
Solutions
Level 1 — Safe converter
public class Converter {
// Tries to convert; if it can't, returns the default value
static int toInt(String text, int defaultValue) {
try {
return Integer.parseInt(text.trim());
} catch (NumberFormatException e) {
return defaultValue; // here it IS fine not to rethrow: it's the method's contract
}
}
// Simulates asking for a number: goes through the inputs until it finds a valid one in range
static int askBetween(String[] inputs, int min, int max) {
for (int i = 0; i < inputs.length; i++) {
try {
int n = Integer.parseInt(inputs[i].trim());
if (n < min || n > max) {
System.out.println(" \"" + inputs[i] + "\": out of range [" + min + ", " + max + "]");
continue; // next attempt
}
System.out.println(" Accepted " + n + " on attempt " + (i + 1));
return n;
} catch (NumberFormatException e) {
System.out.println(" \"" + inputs[i] + "\": not a whole number");
}
}
throw new IllegalStateException("no valid input in " + inputs.length + " attempts");
}
public static void main(String[] args) {
System.out.println(toInt("42", 0) + " " + toInt(" 7 ", 0) + " " + toInt("3.5", -1) + " " + toInt("", -1));
System.out.println("Age:");
int age = askBetween(new String[]{"twenty", "-3", "150", " 19 "}, 0, 120);
System.out.println("Grade:");
try {
askBetween(new String[]{"ten", "11"}, 0, 10);
} catch (IllegalStateException e) {
System.out.println(" Error: " + e.getMessage());
}
System.out.println("Age saved: " + age);
}
}
Why it’s done this way:
- **
toIntcatches and returns a default value. Didn’t we say that catching and “carrying on as if nothing happened” was bad? Here it’s different: the method promises**, through its name and parameters, that if it can’t convert it will returndefaultValue. The caller knows that and chose it. The problem in snippet 3 was that nobody knew the 0 meant “error”. - **
"3.5"returns -1:**Integer.parseIntdoesn’t accept decimals. If you wanted to round, you’d have to useDouble.parseDoubleand convert. - **
tryinside thefor:** every input gets its chance, the lesson from snippet 1. - **The range is checked with
if, not with exceptions:**19and150are perfectly valid integers for Java; being out of range is your rule.continuejumps to the next attempt without running thereturn. - **If the attempts run out,
IllegalStateException:** the method can’t keep its promise (return a valid number), so it can’t return anything sensible. Throwing is the only honest option, and it’s unchecked because the situation means the caller passed it insufficient data.
Level 2 — Validated date
public class Date {
private final int day;
private final int month;
private final int year;
public Date(int day, int month, int year) {
if (month < 1 || month > 12) {
throw new IllegalArgumentException("invalid month: " + month);
}
int max = daysInMonth(month, year);
if (day < 1 || day > max) {
throw new IllegalArgumentException("invalid day: " + day + " (month " + month + " of " + year + " has " + max + " days)");
}
this.day = day; // only stored if everything is correct
this.month = month;
this.year = year;
}
public static boolean isLeapYear(int year) {
return (year % 4 == 0 && year % 100 != 0) || year % 400 == 0;
}
public static int daysInMonth(int month, int year) {
switch (month) {
case 2:
return isLeapYear(year) ? 29 : 28;
case 4: case 6: case 9: case 11:
return 30;
default:
return 31;
}
}
// "12/10/2026" -> Date. Any problem becomes an IllegalArgumentException
public static Date parse(String text) {
String[] parts = text.trim().split("/");
if (parts.length != 3) {
throw new IllegalArgumentException("invalid format: \"" + text + "\" (use dd/mm/yyyy)");
}
try {
int d = Integer.parseInt(parts[0]);
int m = Integer.parseInt(parts[1]);
int y = Integer.parseInt(parts[2]);
return new Date(d, m, y);
} catch (NumberFormatException e) {
// we translate the technical error into a domain one, and keep the original as the cause
throw new IllegalArgumentException("invalid format: \"" + text + "\" (numbers only)", e);
}
}
@Override
public String toString() {
return String.format("%02d/%02d/%04d", day, month, year);
}
}
Why it’s done this way:
- Validating in the constructor guarantees that an invalid
Datecan’t exist: if the constructor throws, the object never gets created. It’s the “with exceptions” version ofsetGradefrom classes and objects. - The month is checked before the day because to know how many days the month has, the month has to be valid. The order of the checks still matters.
- **
parsetranslatesNumberFormatExceptionintoIllegalArgumentException.** Whoever usesDatedoesn’t care thatparseIntis used inside; what matters to them is “the date isn’t valid”. That way, a singlecatch (IllegalArgumentException e)works for every case. - The original exception is kept as the cause (second argument). No information is lost: if someone needs the technical detail, it’s there with
getCause(). - **What if
new Date(...)throws insideparse‘stry?** It throws anIllegalArgumentException, which is not aNumberFormatException(it’s its parent, not its child). Thecatchdoesn’t catch it, it goes up unchanged and reachesmainwith its original message (“invalid day…”). That’s exactly what we want.
2024 is a leap year (divisible by 4 and not by 100) and 2026 isn’t. That’s why 29/02/2024 exists and 29/02/2026 doesn’t. The year 2000 was also a leap year because of the 400 rule; 1900 wasn’t. And calling our class Date is fine as long as you don’t import java.util.Date in the same file.
Level 3 — Bank with its own exceptions
// Parent of every bank problem: catching it catches all its children
public class BankException extends Exception {
public BankException(String message) {
super(message);
}
}
public class AccountNotFoundException extends BankException {
private final String iban;
public AccountNotFoundException(String iban) {
super("account " + iban + " does not exist");
this.iban = iban;
}
public String getIban() { return iban; }
}
public class InsufficientFundsException extends BankException {
private final double shortBy;
public InsufficientFundsException(String iban, double shortBy) {
super(String.format("insufficient funds in %s: short by €%.2f", iban, shortBy));
this.shortBy = shortBy;
}
public double getShortBy() { return shortBy; }
}
public class Account {
private final String iban;
private double balance;
public Account(String iban, double balance) {
this.iban = iban;
this.balance = balance;
}
public String getIban() { return iban; }
public double getBalance() { return balance; }
// Only the bank uses these, and only after checking everything
void take(double amount) { balance -= amount; }
void put(double amount) { balance += amount; }
}
public class Bank {
private final Account[] accounts;
private int numAccounts = 0;
public Bank(int capacity) {
accounts = new Account[capacity];
}
public void open(String iban, double initialBalance) {
if (numAccounts == accounts.length) {
throw new IllegalStateException("the bank is full");
}
accounts[numAccounts++] = new Account(iban, initialBalance);
}
public Account find(String iban) throws AccountNotFoundException {
for (int i = 0; i < numAccounts; i++) {
if (accounts[i].getIban().equals(iban)) {
return accounts[i];
}
}
throw new AccountNotFoundException(iban);
}
public void transfer(String from, String to, double amount) throws BankException {
if (amount <= 0) {
throw new IllegalArgumentException("invalid amount: " + amount);
}
Account a = find(from); // we find BOTH before moving a single cent
Account b = find(to);
if (a == b) {
throw new IllegalArgumentException("source and destination are the same account");
}
if (amount > a.getBalance()) {
throw new InsufficientFundsException(from, amount - a.getBalance());
}
a.take(amount); // from here on nothing can fail
b.put(amount);
}
public double total() {
double t = 0;
for (int i = 0; i < numAccounts; i++) {
t += accounts[i].getBalance();
}
return t;
}
public void list() {
for (int i = 0; i < numAccounts; i++) {
System.out.printf(" %s: €%8.2f%n", accounts[i].getIban(), accounts[i].getBalance());
}
}
}
public class Main {
public static void main(String[] args) {
Bank bank = new Bank(3);
bank.open("ES01", 500);
bank.open("ES02", 100);
bank.open("ES03", 0);
double before = bank.total();
String[][] orders = {
{"ES01", "ES02", "200"},
{"ES02", "ES99", "50"},
{"ES02", "ES01", "400"},
{"ES02", "ES02", "10"},
{"ES01", "ES03", "-5"},
{"ES02", "ES03", "300"}
};
int successful = 0;
for (String[] o : orders) {
String text = o[0] + " -> " + o[1] + " (€" + o[2] + ")";
try {
bank.transfer(o[0], o[1], Double.parseDouble(o[2]));
successful++;
System.out.println("OK " + text);
} catch (InsufficientFundsException e) {
System.out.printf("ERR %s: %s. Transfer €%.2f instead?%n",
text, e.getMessage(), Double.parseDouble(o[2]) - e.getShortBy());
} catch (BankException e) {
System.out.println("ERR " + text + ": " + e.getMessage());
} catch (IllegalArgumentException e) {
System.out.println("ERR " + text + ": " + e.getMessage());
}
}
System.out.println("\nSuccessful: " + successful + " of " + orders.length);
bank.list();
System.out.printf("Total money before: €%.2f · after: €%.2f%n", before, bank.total());
}
}
Why it’s done this way:
- **A common parent (
BankException)** gives you two levels of detail.transferonly declaresthrows BankException, even though it throws two different types; and inmainwe treat one child in a special way (the funds one, with its suggestion) and the rest of the family all at once. If tomorrow you addAccountFrozenException extends BankException,transferdoesn’t change its signature andmainalready catches it. - **The child’s
catchgoes before the parent’s.** IfBankExceptioncame first, it would also catch insufficient funds and the suggestion would never be shown (and Java wouldn’t compile thecatchbelow). - Atomicity: all the checks (amount, both accounts, not the same one, the balance) come before
takeandput. If the destination were looked up after taking the money from the source, the order toES99would make €50 disappear. The last line of the output proves it: €600 went in and €600 is left. - **
takeandputhave no modifier** (package visibility):Bankuses them, but they’re not meant for the rest of the program. That way nobody can skiptransfer‘s checks. - **
getShortBy()is used to work out the suggestion:** you asked for 400, you’re short by 100, so 300 can be transferred. It’s the useful data the exception carries, like the ATM in the practice article.
Declaring in transfer throws AccountNotFoundException, InsufficientFundsException, ... with every child, one by one. It works, but every new exception forces you to change the signature and every place that calls it. Declaring the parent is cleaner.
Watch it step by step in Python Tutor
Python Tutor runs code line by line and draws the variables and the calls. Let’s watch snippet 2, the finally that “changes” the value, live. Choose Java, paste this and step through it with Next:
public class Tutor {
static int attempts() {
int x = 1;
try {
return x;
} finally {
x = 2;
}
}
public static void main(String[] args) {
System.out.println(attempts());
}
}
Output:
1
What to watch:
- When it enters
attempts, a frame appears withx = 1. - When
return x;runs, notice that execution doesn’t leave the method: it jumps to thefinally. At that point the 1 has already been decided as the return value, even though the method hasn’t finished yet. - Inside the
finally,xchanges to 2 in the frame… but the value about to be returned is still 1. - On the way out,
mainreceives that 1. The variable changed; what was being returned didn’t. Now try replacingx = 2;withreturn 2;and watch how thefinally“overrides” the result.
Cheat sheet: exceptions in Java
The essentials in 30 seconds
- When an exception is thrown in a
try, **the rest of thetryis skipped**; Java goes into the firstcatchthat matches and then carries on.finallyalways runs. catchblocks from the most specific to the most general. Child before parent.- Checked (
Exception,IOException): catch it or declare it withthrows. Unchecked (RuntimeException): no obligation. throw new X("message")throws;throws Xwarns in the signature. Your own exception:class MyException extends Exception+super(message).- Never an empty
catch: report, fix or rethrow (if you translate, keep the cause).
Syntax
| I want to… | Code | Result |
|---|---|---|
| Try something that might fail | try { … } catch (NumberFormatException e) { … } | the program carries on |
| Several types, different handling | several catch blocks, specific to general | goes into the first that matches |
| Several types, same handling | catch (NumberFormatException | ArrayIndexOutOfBoundsException e) | multi-catch |
| Something that always runs | finally { … } | also with return or break |
| See what happened | e.getMessage() | the error text |
| See the type | e.getClass().getSimpleName() | "NumberFormatException" |
| Throw an error | throw new IllegalArgumentException("…") | stops the method |
| Warn in the signature | void f() throws IOException | the caller must handle it |
| Your own checked exception | class X extends Exception { X(String m) { super(m); } } | forces handling |
| Your own unchecked exception | class X extends RuntimeException | no obligation |
| Translate keeping the cause | throw new IllegalArgumentException("…", e) | getCause() returns e |
| Close a file automatically | try (BufferedReader r = new BufferedReader(new FileReader(f))) { … } | closed on exit |
Templates from the exercises
Retry with the try inside the loop (Converter):
for (String t : inputs) {
try {
int n = Integer.parseInt(t.trim());
if (n < min || n > max) continue;
return n;
} catch (NumberFormatException e) {
System.out.println("not a number: " + t);
}
}
throw new IllegalStateException("no valid input");
Age → accepted 19 on attempt 4
Translating a technical exception (Date):
try {
return new Date(Integer.parseInt(p[0]), Integer.parseInt(p[1]), Integer.parseInt(p[2]));
} catch (NumberFormatException e) {
throw new IllegalArgumentException("invalid format: " + text, e);
}
"aa/10/2026" → invalid format…, cause NumberFormatException
A family of exceptions (Bank):
try {
bank.transfer(from, to, amount);
} catch (InsufficientFundsException e) { // the child first
// use e.getShortBy()
} catch (BankException e) { // the rest of the family
System.out.println(e.getMessage());
}
€600 before → €600 after
The most common mistakes
| Symptom | Likely cause |
|---|---|
| The program stops with a red stack trace | Uncaught exception: look for the first line of your code in the trace |
unreported exception X; must be caught or declared to be thrown | A checked exception with no try/catch or throws |
exception X has already been caught | A general catch before a specific one |
exception X is never thrown in body of corresponding try statement | You’re catching a checked exception the try can’t throw |
| Only part of the data gets processed | The try is outside the loop |
| Odd result with no error on screen | An empty catch, or one that returns a “just in case” value |
For input string: "7,5" | Decimal with a comma: parseDouble only accepts a dot |
empty String | You tried to convert an empty text |
| A failed operation leaves data half-changed | You change state before you’ve finished validating |
Before you hand it in, ask yourself
- Does each
trywrap only what mustn’t run if something fails (and is it inside the loop when it should be)? - Do my
catchblocks go from most specific to most general, and is none of them empty? - Did I choose well between checked and unchecked, and do my own exceptions carry a clear message and the useful data?
- Do I validate everything before changing anything, so an error doesn’t leave data half-changed?
Did this article help you? Here is how you can support SergioLearns →
