Chapter 9

Files

Everything so far has lived in memory and vanished the moment the script ended. This chapter puts your expenses somewhere they survive: a plain text file, read back in the next time the script runs.

Opening a file

open() gives you a file object, something you can read from or write to:

with open("expenses.txt") as f:
    first_line = f.readline()

print(first_line)
Output
4.50,coffee,flat white before work

The with statement closes the file for you automatically once its block ends, even if something inside the block goes wrong. Always open a file inside a with block; it's the one habit in this chapter worth never skipping.

Notice the blank line after the amount. readline() includes the newline character at the end of the line, \n, which is why printing it leaves an extra blank line: print() adds its own newline on top of the one already there.

Reading line by line

Looping over a file object gives you one line at a time, which is the normal way to read a whole file:

with open("expenses.txt") as f:
    for line in f:
        print(line.strip())
Output
4.50,coffee,flat white before work
32.10,groceries,weekly shop
12.00,transit,monthly pass top-up
8.75,coffee,catching up with sam
45.00,groceries,forgot the weekly shop had a gap

.strip() removes whitespace from both ends of a string, including that trailing \n, which is why the extra blank lines are gone this time.

.split(",") breaks a line into pieces wherever a comma appears, giving you back a list of strings:

line = "4.50,coffee,flat white before work"
pieces = line.split(",")
print(pieces)
Output
['4.50', 'coffee', 'flat white before work']

Unpack that list straight into three names, matching this book's entry fields in order:

line = "4.50,coffee,flat white before work"
raw_amount, category, note = line.split(",")
print(raw_amount)
print(category)
print(note)
Output
4.50
coffee
flat white before work

.readlines(), and why this book avoids it

.readlines() reads the whole file at once and hands back a list of every line:

with open("expenses.txt") as f:
    lines = f.readlines()

print(len(lines))
print(lines[0])
Output
5
4.50,coffee,flat white before work

That works, and for a small file like this one it's perfectly fine. But it means the entire file has to fit in memory at once, as one list, before you process a single line. Looping directly over the file object, the way every other example in this chapter does, reads one line at a time and never holds more than that in memory, which matters once a file gets large. For this book's tracker, either approach works; looping directly is the habit worth keeping by default.

Loading a whole file into entries

Put the pieces together: open the file, read each line, split it, convert the amount, and build an entry dict, this book's fixed three-key shape from Chapter 6.

def load_expenses(path):
    expenses = []
    with open(path) as f:
        for line in f:
            line = line.strip()
            if not line:
                continue
            raw_amount, category, note = line.split(",")
            expenses.append({
                "amount": float(raw_amount),
                "category": category,
                "note": note,
            })
    return expenses

expenses = load_expenses("expenses.txt")
print(len(expenses))
print(expenses[0])
Output
5
{'amount': 4.5, 'category': 'coffee', 'note': 'flat white before work'}

if not line: continue skips any blank lines rather than crashing trying to unpack an empty string into three pieces.

Writing

"w" opens a file for writing, replacing whatever was there. "a" opens it for appending, adding to the end without disturbing the existing content:

with open("scratch.txt", "w") as f:
    f.write("first line\n")

with open("scratch.txt", "a") as f:
    f.write("second line\n")

with open("scratch.txt") as f:
    print(f.read())
Output
first line
second line

f.write() doesn't add a newline for you the way print() does, which is why each call above ends its own string with \n. f.read() pulls the whole file back as one string in a single call, useful for a quick look like this.

open()'s second argument is called the mode, and this chapter has now used three of them:

Mode Meaning
"r" read (the default; leaving the mode out means this)
"w" write, replacing the file if it exists, creating it if it doesn't
"a" append, adding to the end, creating the file if it doesn't exist

A fourth, "x", creates a new file and raises FileExistsError if one is already there, useful the rare time you specifically want to avoid overwriting something by accident. "r" is the default: open(path) and open(path, "r") mean exactly the same thing, which is why every reading example in this book so far left the mode out entirely.

Saving entries back out

The mirror of load_expenses(): build one comma-joined line per entry, matching the field order amount,category,note, and write them all out.

def save_expenses(path, expenses):
    with open(path, "w") as f:
        for entry in expenses:
            f.write(f"{entry['amount']},{entry['category']},{entry['note']}\n")

expenses = [
    {"amount": 4.50, "category": "coffee", "note": "flat white"},
    {"amount": 12.00, "category": "transit", "note": ""},
]
save_expenses("saved.txt", expenses)

with open("saved.txt") as f:
    print(f.read(), end="")
Output
4.5,coffee,flat white
12.0,transit,

Round-trip it to check the save and load functions agree with each other:

reloaded = load_expenses("saved.txt")
print(reloaded == expenses)
Output
True

An honest limit

.split(",") has a real weakness: a note containing its own comma would split into the wrong number of pieces and crash load_expenses() on unpacking. This chapter's fixture notes were written without any commas in them on purpose, so the approach here is genuinely correct for the data it's given, not secretly broken. Handling a comma inside a field properly is exactly what the csv module is for, covered in full in Python File Handling, already part of this series. If your own notes need commas, that book is where to go next; this chapter's plain approach is for learning the shape of the problem, not the last word on solving it.

Practice

Try each of these before you read the solution under it.

  1. Load expenses.txt with load_expenses() and print the total of all the amounts, using the total() function from Chapter 7 (rewrite it here).
  2. Write a function count_by_category(expenses) that returns a dict mapping each category to how many entries have it, then run it on the loaded fixture data.
  3. Load the fixture file, append one new entry for a $6.00 "coffee" expense with the note "treat", save it to "updated.txt", then load "updated.txt" back and print its length.
  4. Write append_note(path, text) that opens path in append mode and writes text followed by a newline, without disturbing what's already there. Call it twice on a new file, "log.txt", then print the file's full contents.

Solutions

1.

def total(expenses):
    running = 0
    for entry in expenses:
        running += entry["amount"]
    return running

expenses = load_expenses("expenses.txt")
print(total(expenses))
Output
102.35

2.

def count_by_category(expenses):
    counts = {}
    for entry in expenses:
        category = entry["category"]
        counts[category] = counts.get(category, 0) + 1
    return counts

expenses = load_expenses("expenses.txt")
print(count_by_category(expenses))
Output
{'coffee': 2, 'groceries': 2, 'transit': 1}

3. counts.get(category, 0) + 1 from the previous solution is the same "default, then update" shape used here for the new entry.

expenses = load_expenses("expenses.txt")
expenses.append({"amount": 6.00, "category": "coffee", "note": "treat"})
save_expenses("updated.txt", expenses)

reloaded = load_expenses("updated.txt")
print(len(reloaded))
Output
6

4.

def append_note(path, text):
    with open(path, "a") as f:
        f.write(text + "\n")

append_note("log.txt", "started tracking")
append_note("log.txt", "added first entry")

with open("log.txt") as f:
    print(f.read(), end="")
Output
started tracking
added first entry

Where this leaves you

You can read a text file line by line, split it into this book's entry fields, and write entries back out, all with nothing but open(). You also know exactly where this chapter's simple approach breaks and where to look when you need more. Chapter 10 handles what happens when a file is missing or a line doesn't parse, instead of letting the script crash.