Exceptions in Java — practice with a grades importer, an ATM and a file reader (and the “Error” that told me nothing)

The first time I used try/catch properly, I was so pleased that my grades importer no longer crashed that I wrapped everything in a single block:

try {
    // ...the whole program...
} catch (Exception e) {
    System.out.println("Error");
}

No more red stack traces. What came out instead was the word Error. Nothing else. Which line of the file? Which field? Was a value missing or badly written? No idea. I’d swapped a program that crashed for one that failed silently, which is worse. Since then I follow one rule: each catch catches one specific kind of problem and gives a message that says where and why. That rule is the backbone of today’s three programs.

If you’ve already read the exceptions theory, this article is for putting it into practice with three complete programs: a grades importer that doesn’t fall over with badly written lines, an ATM with its own exceptions, and a file reader using try-with-resources. They’re all medium difficulty and work with data that looks like the real thing: grades with decimal commas, missing fields, impossible amounts and files that don’t exist. Each one comes with commented code, the output and the questions that usually come up.

Before you start

Make sure you’re clear on:

  • When an exception is thrown inside a try, **the rest of the try is skipped** and Java looks for the first catch that matches.
  • catch blocks go from the most specific to the most general (NumberFormatException before IllegalArgumentException, which is its parent).
  • The difference between throw (throwing) and throws (warning in the signature), and between checked exceptions (they force you) and unchecked ones (they don’t).
  • Your own exception is created by extending Exception and passing the message with super(...).

If any of these sounds shaky, review the theory before going on.

The third program creates and reads a file called grades.txt. It’s saved in the folder you run the program from (in IntelliJ, the project root). If you can’t find it, look there.

Program 1: a grades importer that doesn’t fall over

We’re going to process lines in the format name;course;grade, like the ones a spreadsheet would export. The data comes as it comes: grades with decimal commas, lines missing a field, grades that aren’t numbers, impossible grades and empty fields. The program has to process every line, explain exactly what’s wrong with each one that fails and work out the average of the valid ones.

The idea

If you read the Strings practice, you’ll remember that there we checked the grade by hand, character by character, because we didn’t know how to handle NumberFormatException yet. Now we do: we let Double.parseDouble have a go and, if it can’t, we catch its exception.

We split the work into two parts:

  • A method readGrade(line) that returns the grade or throws an exception with the reason. It doesn’t print or decide anything: it only reports.
  • main, which goes through the lines with a try inside the loop (so one bad line doesn’t stop the rest), catches each kind of problem separately and keeps count with a finally.

Before coding, think about what can go wrong with a line and who should detect it: you check the number of fields and the range yourself (throw); parseDouble detects for you that the text isn’t a number.

The code

public class GradeImporter {

    // Returns the grade from a "name;course;grade" line or throws an exception explaining the problem
    static double readGrade(String line) {
        String[] fields = line.split(";", -1);
        if (fields.length != 3) {
            throw new IllegalArgumentException("expected 3 fields but found " + fields.length);
        }
        String text = fields[2].trim().replace(',', '.');    // we accept "7,5" as well as "7.5"
        double grade = Double.parseDouble(text);              // may throw NumberFormatException
        if (grade < 0 || grade > 10) {
            throw new IllegalArgumentException("grade out of range (" + grade + ")");
        }
        return grade;
    }

    public static void main(String[] args) {
        String[] lines = {
            "Ana Pérez;FP2;8.5",
            "Luis Gómez;FP2;7,5",
            "Marta Ruiz;FP2",
            "Pablo Sosa;FP2;9x",
            "Elena Díaz;FP2;12",
            "Sara Vega;FP2;",
            "Iván Mora;FP2;10"
        };

        double sum = 0;
        int valid = 0;
        int processed = 0;

        for (int i = 0; i < lines.length; i++) {
            try {
                double grade = readGrade(lines[i]);
                sum += grade;
                valid++;
                System.out.printf("Line %d: OK (%.2f)%n", i + 1, grade);
            } catch (NumberFormatException e) {
                // goes BEFORE IllegalArgumentException: it's its child
                System.out.println("Line " + (i + 1) + ": the grade is not a number -> " + e.getMessage());
            } catch (IllegalArgumentException e) {
                System.out.println("Line " + (i + 1) + ": " + e.getMessage());
            } finally {
                processed++;                                   // counted no matter what
            }
        }

        System.out.printf("%nProcessed: %d · valid: %d · with errors: %d%n", processed, valid, processed - valid);
        if (valid > 0) {
            System.out.printf("Average: %.2f%n", sum / valid);
        }
    }
}

Output

Line 1: OK (8.50)
Line 2: OK (7.50)
Line 3: expected 3 fields but found 2
Line 4: the grade is not a number -> For input string: "9x"
Line 5: grade out of range (12.0)
Line 6: the grade is not a number -> empty String
Line 7: OK (10.00)

Processed: 7 · valid: 3 · with errors: 4
Average: 8.67

Check it by hand: (8.5 + 7.5 + 10) / 3 = 8.666… ✔. And look at line 2: the "7,5" that brought the program down in the theory now goes through without a problem, because we swap the comma for a dot before converting. The four bad lines each have their own reason, and none of them stopped the next line from being processed.

empty String is the message parseDouble gives when it gets an empty text. It’s Sara Vega’s line, which ends in ;. Thanks to split(";", -1) the empty field is kept and the right error is “not a number” instead of “missing fields”.

Questions you might ask

**Why does catch (NumberFormatException e) go before the IllegalArgumentException one?** Because NumberFormatException is an IllegalArgumentException (it’s its child). If the general one came first, it would also catch the format errors and the one below would never run. Java wouldn’t even compile it: exception NumberFormatException has already been caught.

**Why doesn’t readGrade have throws in its signature if it throws exceptions?** Because the two it can throw, IllegalArgumentException and NumberFormatException, are unchecked (children of RuntimeException). Java doesn’t force you to declare them. You could write throws IllegalArgumentException as documentation, but it’s not required.

**What’s the finally for here? To count the processed lines no matter what**. If you put processed++ at the end of the try, it would only count the valid ones (on bad lines the rest of the try is skipped); if you put it in every catch, you’d have to repeat it. In the finally you write it once and it never misses.

**Why does readGrade throw an exception instead of returning, say, -1 when something goes wrong?** Because -1 doesn’t explain what failed, and the caller could forget to check it and add a -1 to the average. An exception carries its message and forces a decision: if nobody catches it, the program stops instead of giving a false result.

What if I want the line number inside the exception message? readGrade doesn’t know which line it’s on: it only gets the text. The right approach is what main does: whoever knows the context (the line number) adds it to the message when catching it.

Calling Double.parseDouble(fields[2]) before checking fields.length. With Marta’s line, fields[2] would throw an ArrayIndexOutOfBoundsException that we’re not catching, and the program would stop. The order of the checks still matters, even with try/catch.

Try it yourself

  • Add a check that the course is FP1, FP2 or IC2 (case-insensitive), with its own message.
  • Create an InvalidLineException (checked) with the line number as a field, and make main throw it wrapping the original exception.

Program 2: an ATM with its own exceptions

Now an ATM. The account has a PIN and a balance, and the ATM gets a list of operations (PIN typed and amount). We have to cover everything that can go wrong at a real ATM: wrong PIN, locking after three failures in a row, amounts that aren’t multiples of 10, text where there should be a number, and insufficient funds. And every operation, whether it goes well or badly, must be logged.

The idea

Here it’s worth separating two kinds of problem:

  • Business problems that the program must always handle: there’s not enough money, the account has been locked. For these we create our own checked exceptions (InsufficientFundsException, AccountLockedException), so Java forces anyone using the account to handle them.
  • Badly entered data (wrong PIN, impossible amount): IllegalArgumentException, unchecked, just like in the importer.

And a design detail: the funds exception carries the useful data (how much is missing), just like the InvalidGradeException in the theory carried the grade.

Before coding, think about what should happen when the account gets locked: there’s no point processing more operations. How do you leave the loop from a catch? Is that operation still logged?

The code

public class InsufficientFundsException extends Exception {

    private final double shortBy;

    public InsufficientFundsException(double shortBy) {
        super(String.format("Insufficient funds: you're short by €%.2f", shortBy));
        this.shortBy = shortBy;
    }

    public double getShortBy() { return shortBy; }
}
public class AccountLockedException extends Exception {

    public AccountLockedException() {
        super("Account locked: 3 wrong PINs in a row");
    }
}
public class Account {

    private static final int MAX_FAILURES = 3;

    private final String pin;
    private double balance;
    private int failures = 0;

    public Account(String pin, double balance) {
        this.pin = pin;
        this.balance = balance;
    }

    public void checkPin(String attempt) throws AccountLockedException {
        if (failures >= MAX_FAILURES) {
            throw new AccountLockedException();               // it was already locked
        }
        if (!pin.equals(attempt)) {
            failures++;
            if (failures == MAX_FAILURES) {
                throw new AccountLockedException();           // this failure locks it
            }
            throw new IllegalArgumentException("Wrong PIN (attempts left: " + (MAX_FAILURES - failures) + ")");
        }
        failures = 0;                                         // correct PIN: the failure count resets
    }

    public void withdraw(double amount) throws InsufficientFundsException {
        if (amount <= 0 || amount % 10 != 0) {
            throw new IllegalArgumentException("invalid amount: " + amount + " (multiples of 10)");
        }
        if (amount > balance) {
            throw new InsufficientFundsException(amount - balance);
        }
        balance -= amount;                                    // only reached if everything is correct
    }

    public double getBalance() { return balance; }
}
public class Atm {

    public static void main(String[] args) {
        Account account = new Account("1234", 120);

        // Each operation: {PIN typed, amount typed}
        String[][] operations = {
            {"1234", "50"},
            {"1234", "35"},
            {"1234", "twenty"},
            {"1234", "100"},
            {"1111", "20"},
            {"1234", "20"},
            {"0000", "10"},
            {"9999", "10"},
            {"8888", "10"},
            {"1234", "10"}
        };

        int receipt = 0;
        for (String[] op : operations) {
            receipt++;
            try {
                account.checkPin(op[0]);
                double amount = Double.parseDouble(op[1]);
                account.withdraw(amount);
                System.out.printf("#%d Withdrew €%s. Balance: €%.2f%n", receipt, op[1], account.getBalance());
            } catch (InsufficientFundsException e) {
                System.out.printf("#%d %s (you asked for €%s)%n", receipt, e.getMessage(), op[1]);
            } catch (AccountLockedException e) {
                System.out.println("#" + receipt + " " + e.getMessage() + ". Card retained.");
                break;                                        // we leave the loop... but first, finally
            } catch (NumberFormatException e) {
                System.out.println("#" + receipt + " \"" + op[1] + "\" is not an amount");
            } catch (IllegalArgumentException e) {
                System.out.println("#" + receipt + " " + e.getMessage());
            } finally {
                System.out.println("   (operation " + receipt + " logged)");
            }
        }
    }
}

Output

#1 Withdrew €50. Balance: €70.00
   (operation 1 logged)
#2 invalid amount: 35.0 (multiples of 10)
   (operation 2 logged)
#3 "twenty" is not an amount
   (operation 3 logged)
#4 Insufficient funds: you're short by €30.00 (you asked for €100)
   (operation 4 logged)
#5 Wrong PIN (attempts left: 2)
   (operation 5 logged)
#6 Withdrew €20. Balance: €50.00
   (operation 6 logged)
#7 Wrong PIN (attempts left: 2)
   (operation 7 logged)
#8 Wrong PIN (attempts left: 1)
   (operation 8 logged)
#9 Account locked: 3 wrong PINs in a row. Card retained.
   (operation 9 logged)

Go through it by hand: 120 − 50 = 70; the €100 in operation 4 can’t be withdrawn because you’re short by 100 − 70 = 30; after operation 6 there’s 70 − 20 = 50 left. Notice operation 7 too: it says “attempts left: 2” again because operation 6, with the right PIN, reset the failures. And operation 10 never gets processed: the break in 9 leaves the loop, although its finally runs first.

Questions you might ask

**Why is InsufficientFundsException checked while a wrong PIN is an IllegalArgumentException? It’s a design decision. Running out of money is a normal business situation that any** code using Account must deal with, so we force it with a checked exception. A mistyped PIN we treat as bad input data, just like the amount, with the standard unchecked one. Either way, the message explains what happened.

**What exactly does break do inside a catch if there’s a finally?** It leaves the loop, but that iteration’s finally runs anyway before leaving. That’s why operation 9 shows as logged. The same would happen with a return.

**Why does the NumberFormatException catch come before the IllegalArgumentException one?** Same reason as in the importer: it’s its child. That way “twenty” gets its own message (“is not an amount”) instead of parseDouble‘s generic one.

**Why does withdraw do balance -= amount at the end and not at the start? Because if any check throws an exception, the method stops right there and the balance doesn’t change**. If you subtracted first and checked afterwards, a failed operation could leave the account short. Check everything first; change things last.

**What’s getShortBy() for if the message already says it? So the code can use** the value, not just print it: for example, to offer “would you like to withdraw €70 instead?”. The message is for people; the exception’s fields are for the program.

Catching AccountLockedException and carrying on with the loop as if nothing happened. The program doesn’t crash, but it keeps asking a locked account for its PIN. Catching an exception isn’t just “so it doesn’t come out red”: it’s deciding what to do, and here the right thing is to stop.

When you create your own exception, think about what data whoever catches it would need in order to act (how much is missing, which grade it was, which line) and store it as fields with getters.

Try it yourself

  • Add a daily limit of €300: if a withdrawal exceeds it, throw a DailyLimitException with the amount that can still be withdrawn today.
  • Change the design so that AccountLockedException extends RuntimeException. What does the compiler stop asking you for? Do you think it’s better or worse?

Program 3: reading grades from a file

The last program works with a real file. First it creates grades.txt with a few lines (one badly written and one incomplete) and then reads it to work out the average. It also tries to read a second file that doesn’t exist. The program has to tell the difference between a problem with one line (skip that line and carry on) and a problem with the whole file (nothing can be worked out).

The idea

We have two levels of error, and each one is handled in a different place:

  • Errors in one line (a grade that isn’t a number, a line with no grade): caught inside the reading loop, with an inner try/catch, so the reading carries on.
  • Errors with the file (doesn’t exist, can’t be read): these are IOException, checked. The average method doesn’t know what to do with them, so it declares them with throws and lets main decide.

To open the file we use try-with-resources: Java closes it on its own when leaving the block, whether everything goes well or an exception is thrown.

Before coding, ask yourself exactly where each thing can fail: opening the file (new FileReader), reading a line (readLine), splitting it (split and fields[1]) and converting it (parseDouble). Each failure belongs to one of the two levels.

The code

import java.io.BufferedReader;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;
import java.io.PrintWriter;

public class GradeReader {

    // Creates the sample file (in a real program it would already exist)
    static void createFile(String path) throws IOException {
        try (PrintWriter out = new PrintWriter(new FileWriter(path))) {
            out.println("Ana;8.5");
            out.println("Luis;7,5");
            out.println("Marta;nine");
            out.println("Pablo");
            out.println("Sara;6");
        }                                                   // it closes itself here
    }

    // Reads the file and returns the average. Errors in ONE line are handled here;
    // errors with the FILE (doesn't exist, can't be read) are passed up with throws.
    static double average(String path) throws IOException {
        double sum = 0;
        int valid = 0;
        int lineNumber = 0;
        try (BufferedReader reader = new BufferedReader(new FileReader(path))) {
            String line;
            while ((line = reader.readLine()) != null) {    // null = end of file
                lineNumber++;
                try {
                    String[] fields = line.split(";");
                    double grade = Double.parseDouble(fields[1].trim().replace(',', '.'));
                    sum += grade;
                    valid++;
                } catch (NumberFormatException | ArrayIndexOutOfBoundsException e) {
                    System.out.println("  Line " + lineNumber + " skipped (" + line + "): "
                            + e.getClass().getSimpleName());
                }
            }
        }
        if (valid == 0) {
            throw new IOException("no valid grade in " + path);
        }
        return sum / valid;
    }

    public static void main(String[] args) {
        try {
            createFile("grades.txt");
        } catch (IOException e) {
            System.out.println("Couldn't create the file: " + e.getMessage());
            return;
        }

        String[] paths = {"grades.txt", "grades-2025.txt"};
        for (String path : paths) {
            System.out.println("Reading " + path + "...");
            try {
                System.out.printf("  Average: %.2f%n", average(path));
            } catch (IOException e) {
                System.out.println("  Couldn't read: " + e.getMessage());
            }
        }
    }
}

Output

Reading grades.txt...
  Line 3 skipped (Marta;nine): NumberFormatException
  Line 4 skipped (Pablo): ArrayIndexOutOfBoundsException
  Average: 7.33
Reading grades-2025.txt...
  Couldn't read: grades-2025.txt (No such file or directory)

Check it: the valid grades are 8.5, 7.5 and 6, and (8.5 + 7.5 + 6) / 3 = 7.33 ✔. The two bad lines are skipped without stopping the reading, and the missing file doesn’t bring the program down: main catches it and moves on to the next one.

The “doesn’t exist” message is written by the operating system. On Windows you’ll see something like grades-2025.txt (The system cannot find the file specified). The exception type is the same: FileNotFoundException.

Questions you might ask

**Why is there one try inside another?** Because there are two levels of error. The outer try (the resource one) takes care of the file: it opens it and closes it. The inner one takes care of each line: if one fails, it’s skipped and the while carries on. With only the outer one, the first bad line would stop all the reading.

**Why does average declare throws IOException instead of catching it?** Because average doesn’t know what to do if the file doesn’t exist: tell the user? try another one? use a default average? That’s for the caller to decide. By declaring it, the compiler forces main to handle it (it’s checked), so we can’t forget.

When is the file closed? As soon as we leave the try (...) block, no matter what: when the while finishes, or if an IOException were thrown halfway through reading. You don’t write any close(). The line exceptions never get out of that block, because the inner try catches them.

**What does (line = reader.readLine()) != null do?** It reads the next line, stores it in line and checks whether it’s null. readLine() returns null when there are no lines left: it’s the classic way to go through a whole file.

**Why do we throw an IOException ourselves if there are no valid grades?** Because without grades you can’t work out an average (we’d divide by 0). Throwing an exception with a clear message is better than returning NaN or 0, which could pass for a real result.

Opening the file with new FileReader(...) outside a try-with-resources and closing it at the end of a normal try. If an exception is thrown before the close(), the file stays open. With try (...) that can’t happen.

Try it yourself

  • Make the program write a second file, errors.txt, with the skipped lines and the reason for each one.
  • Replace the multi-catch with two separate catch blocks with different messages: “grade is not numeric” and “grade is missing”.

Common mistakes in this practice

  • **A single catch (Exception e) for everything,** with a generic message: you won’t know what failed or where. Catch specific types and explain the reason.
  • **Putting the try outside the loop:** the first bad value stops all the processing.
  • **Getting the catch order wrong:** the most specific (NumberFormatException) goes before the general one (IllegalArgumentException).
  • Changing state before validating: if withdraw subtracts and then checks, a failed operation leaves the account wrong. Validate first, change afterwards.
  • Catching an exception and doing nothing useful with it: carrying on with a locked account, or an empty catch that hides the error.
  • **Opening files without try-with-resources:** if something fails, the file may stay open.

Watch it step by step in Python Tutor

Python Tutor runs code line by line and draws the variables and the calls. Choose Java, paste this cut-down ATM and step through it with Next:

public class Tutor {
    static class InsufficientFundsException extends Exception {
        InsufficientFundsException(String message) { super(message); }
    }
    static double balance = 100;
    static void withdraw(double amount) throws InsufficientFundsException {
        if (amount > balance) {
            throw new InsufficientFundsException("Short by €" + (amount - balance));
        }
        balance -= amount;
    }
    public static void main(String[] args) {
        double[] withdrawals = {30, 90, 50};
        for (double w : withdrawals) {
            try {
                withdraw(w);
                System.out.println("OK, " + balance + " left");
            } catch (InsufficientFundsException e) {
                System.out.println(e.getMessage());
            } finally {
                System.out.println("Receipt printed");
            }
        }
    }
}

Output:

OK, 70.0 left
Receipt printed
Short by €20.0
Receipt printed
OK, 20.0 left
Receipt printed

What to watch:

  1. On the first pass, withdraw(30) goes in, subtracts and comes back: balance goes from 100 to 70 and execution continues with the “OK”.
  2. On the second, with 90, watch the throw: an InsufficientFundsException object is created (it appears on the heap with its message) and withdraw‘s frame vanishes without reaching balance -= amount. That’s why the balance stays at 70.
  3. Execution jumps straight to main‘s catch, without going through the “OK” println.
  4. On all three passes, exception or not, execution goes through the finally. Count the “Receipt printed” lines: there’s always one per pass.

Summary

  • Catch specific types of exception and give messages that say where and why something failed; a generic catch (Exception e) hides problems.
  • The try goes inside the loop when every element should get its chance.
  • Order your catch blocks from the most specific to the most general; Java won’t compile the opposite order.
  • finally always runs, even with break or return: use it to count, log or close.
  • Use your own checked exceptions for business situations the caller must handle, and store the useful data in them.
  • Open files with try-with-resources and separate errors in one line (skip and carry on) from errors with the file (propagate with throws).

Checklist: have you practised it?

  • Have you run the three programs and checked the outputs?
  • Can you explain why NumberFormatException is caught before IllegalArgumentException?
  • Do you know what happens to the finally when there’s a break in the catch?
  • Do you understand why withdraw changes the balance at the end and not at the start?
  • Can you tell which errors average handles and which it passes up with throws?
  • Have you tried the “Try it yourself” tasks without looking at the theory?

If you said yes to almost everything, you’re ready for the exceptions exercises, where I don’t hand you the code. See you there!

Next step: exercisesExceptions in Java — exercises with a converter, dates and a bank (and the try I put in the wrong place)

Did this article help you? Here is how you can support SergioLearns →

Sigue aprendiendo

Leave a Reply

Your email address will not be published. Required fields are marked *