Skip to Content
Course content

226: Building a Personal Expense Tracker with File Persistence

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I remember the first time I tried to make a simple program "remember" data between restarts. I thought I had it all figured out: write some strings to a file, read them back, and boom—persistence. But then I kept getting a NumberFormatException that seemed to happen randomly, even though my data looked perfectly fine in the text editor. It took me an hour of staring at the screen to realize I was trying to parse a ghost.

The Dreaded NumberFormatException on the Last Line

// Imagine we have a file 'expenses.txt' with:
// Coffee,5.50
// Rent,1200.00
// Internet,60.00
// [Empty Line Here]

public List<Expense> loadExpenses() throws IOException {
    List<Expense> expenses = new ArrayList<>();
    BufferedReader reader = new BufferedReader(new FileReader("expenses.txt"));
    String line;
    while ((line = reader.readLine()) != null) {
        String[] parts = line.split(",");
        String description = parts[0];
        double amount = Double.parseDouble(parts[1]); // CRASH happens here!
        expenses.add(new Expense(description, amount));
    }
    reader.close();
    return expenses;
}

If you run this, it works great... until you have a trailing newline at the end of your file. Most text editors add a hidden newline at the end of the document. When readLine() hits that last empty line, it doesn't return null immediately; it returns an empty string. split(",") then produces an array with one empty element, and when you try to access parts[1] or parse that empty string into a double, Java throws a fit.

Filtering Out the Empty Noise

The fix is simple, but it's a habit you need to build: never trust the input file. I always add a guard clause to skip empty or blank lines. It makes your code resilient to how different operating systems handle line endings.

while ((line = reader.readLine()) != null) {
    if (line.isBlank()) continue; // Skip the ghost lines
    
    String[] parts = line.split(",");
    if (parts.length < 2) continue; // Also protect against malformed lines
    
    String description = parts[0];
    double amount = Double.parseDouble(parts[1]);
    expenses.add(new Expense(description, amount));
}

By using isBlank() (introduced in Java 11), we ignore lines that are either totally empty or just contain whitespace. I also added a length check on the parts array. If a user accidentally deletes a price in the text file, the program won't crash; it'll just skip that bad entry. It's better to miss one expense than to have the whole app crash on startup.

Structuring the CSV for Easy Recovery

For a personal expense tracker, we don't need a full database. A CSV (Comma Separated Values) format is plenty. However, the trick to making this maintainable is to let the Expense object handle its own "serialization." Don't scatter split(",") logic all over your main class. Instead, give your Expense class a method to convert itself to a string and a static factory method to recreate itself from a string.

This way, if you decide later to add a "Date" field or a "Category" field, you only have to change the logic in one place—the Expense class—rather than hunting through every file-reading loop in your project.

Ensuring the Data Actually Hits the Disk

When writing the data back, you'll likely use a PrintWriter or a BufferedWriter. One thing that catches people off guard is the buffer. Java doesn't always write to the disk the millisecond you call println(); it holds the data in memory for efficiency.

If your program crashes or you forget to close the stream, that data stays in the buffer and never actually reaches the file. I strongly recommend the try-with-resources statement. It ensures that the file is closed (and thus flushed) regardless of whether the code finishes successfully or throws an exception. I've lost too many hours of test data to "missing" file writes because I forgot to call .close().

public void saveExpenses(List<Expense> expenses) throws IOException {
    try (PrintWriter writer = new PrintWriter(new FileWriter("expenses.txt"))) {
        for (Expense e : expenses) {
            writer.println(e.toCsv());
        }
    } // writer is automatically closed and flushed here
}



📋 Practical Task

Exercise: Implementing a Category-Based Budget Filter for the Expense File

Your expense tracker is working, but now you want to be able to load only specific types of spending. Currently, the loadExpenses() method loads every single line from expenses.txt into memory.

Your Task: Modify the file-loading logic to implement a loadExpensesByCategory(String targetCategory) method.

  • Assume your CSV format has been updated to Description,Amount,Category (e.g., "Latte,5.50,Food").
  • The method should read the file line-by-line.
  • It should only add the Expense object to the returned list if the category matches the targetCategory (case-insensitive).
  • Ensure you maintain the "guard clauses" learned in the lesson to prevent NumberFormatException or ArrayIndexOutOfBoundsException if the file has empty lines or malformed data.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.