Lesson 8 of 16 · Level 2: Intermediate

Python Files, Error Handling and Your First Project

  • ⏱ 14 min read
  • ❓ 4 review questions
  • ✍️ Exercise with solution
  • 📥 Downloadable template
In this lesson
  1. Handling errors with try / except
  2. Reading and writing text files
  3. CSV files: data shaped like a table
  4. JSON files: structured data
  5. Real case: a CSV with broken rows
  6. Intermediate project: a personal expense tracker
  7. Common mistakes with files and exceptions
  8. Summary
What you'll learn
  • Handle errors with try/except.
  • Read and write text, CSV and JSON files.
  • Build a complete command-line expense tracker.
  • Wrap up the Intermediate level with a real project.
  • Process a CSV with broken rows without crashing the program.
📥 Practice this lesson

Put the file in the same folder as your program to test reading the CSV and the summary by category.

  • expenses.csv · 40 sample household expenses for the expense tracker

This lesson closes the Intermediate level, and it's where your programs become genuinely useful: they'll save and read data in files so nothing is lost when they close, and they'll handle errors so they don't crash when something goes wrong. At the end you'll build a complete project: a personal expense tracker that stores everything in a CSV file you can open in Excel or Google Sheets.

3file modes cover almost everything: read "r", write "w" and append "a"
40sample expenses come in the practice file expenses.csv so you can test the project
1with block is all it takes for a file to close itself, even if an error happens

Handling errors with try / except

When something fails (the user types letters where you expected a number, a file doesn't exist, you divide by zero), Python raises an exception and the program stops. With try/except you catch it and decide what to do:

try:
    age = int(input("Age: "))
    print(f"Next year you'll be {age + 1}")
except ValueError:
    print("That's not a whole number")

The most common exceptions

ExceptionWhen it happens
ValueErrorA conversion that can't work: int("hello")
ZeroDivisionErrorDividing by zero
KeyErrorA missing key in a dictionary
IndexErrorA missing position in a list
FileNotFoundErrorThe file doesn't exist
TypeErrorAn operation with incompatible types: "5" + 3

The full structure

try:
    a = float(input("Dividend: "))
    b = float(input("Divisor: "))
    result = a / b
except ValueError:
    print("Please enter valid numbers")
except ZeroDivisionError:
    print("You can't divide by zero")
else:
    print(f"Result: {result:.2f}")   # only if there was no error
finally:
    print("Done")                    # always runs

With 10 and 0 you get "You can't divide by zero" and then "Done"; with 10 and 4, "Result: 2.50" and "Done." Python always walks through the structure in the same order:

  1. tryAttempt the codeIf a line fails, Python jumps straight to the matching except; the rest of the try is skipped.
  2. exceptHandle the expected errorOnly the block whose type matches (ValueError, ZeroDivisionError…). If none matches, the program still stops.
  3. elseContinue if all went wellRuns only when the try finished without errors.
  4. finallyAlways wrap upNo matter what. Ideal for closing messages or releasing resources.

Ask for a number until it's valid

Here's a function you'll reuse in almost every program. It even accepts amounts typed as "$1,024.50":

def ask_amount(prompt):
    while True:
        text = input(prompt).strip().replace("$", "").replace(",", "")
        try:
            return float(text)
        except ValueError:
            print("Invalid value, please try again.")
Don't catch everything blindly

Avoid a bare except: or except Exception: on its own, because it hides programming bugs you need to see. Catch the specific exceptions you expect.

1. Which exception does int("hello") raise?

  • TypeError
  • ValueError
  • ZeroDivisionError

ValueError appears when the type is right (a string) but its content can't be converted.

Reading and writing text files

The right way to work with files is with with, which closes them automatically even if there's an error:

# Write (creates the file or overwrites it)
with open("notes.txt", "w", encoding="utf-8") as f:
    f.write("First line\n")
    f.write("Second line\n")

# Append to the end without erasing what's there
with open("notes.txt", "a", encoding="utf-8") as f:
    f.write("Third line\n")

# Read line by line
with open("notes.txt", "r", encoding="utf-8") as f:
    for line in f:
        print(line.strip())

"r" · read

The default mode. If the file doesn't exist, it raises FileNotFoundError.

"w" · write

Creates the file or erases its contents and writes from scratch.

"a" · append

Writes at the end and keeps everything already there. The expense tracker uses it.

Always pass encoding="utf-8": it prevents trouble with characters like "é" or emoji, especially on Windows. Starting with Python 3.15, UTF-8 is the default encoding, but writing it explicitly keeps your program working the same way on older versions.

2. You open an existing file in "w" mode. What happens?

  • The text is added to the end
  • You get an error
  • Its contents are erased and it's written from scratch

"w" overwrites; to add to the end without erasing, use "a".

CSV files: data shaped like a table

CSV is the universal format for exchanging tables: Excel, Google Sheets, QuickBooks exports and every database can read it. Python includes the csv module:

import csv

expenses = [
    {"date": "2026-10-01", "category": "food", "amount": 35.5},
    {"date": "2026-10-02", "category": "transportation", "amount": 12.0},
]

# Write
with open("my_expenses.csv", "w", newline="", encoding="utf-8") as f:
    writer = csv.DictWriter(f, fieldnames=["date", "category", "amount"])
    writer.writeheader()
    writer.writerows(expenses)

# Read
with open("my_expenses.csv", newline="", encoding="utf-8") as f:
    for row in csv.DictReader(f):
        print(row["category"], float(row["amount"]))

When you read a CSV, every value arrives as text, so convert numbers with float(). If Excel shows "Café" instead of "Café" when you open your file, write it with encoding="utf-8-sig", which adds a small marker Excel uses to recognize UTF-8.

JSON files: structured data

JSON stores dictionaries and lists exactly as they are. It's the format of web APIs and many configuration files:

import json

contacts = {"Zoe Kim": "312-555-0199", "Adam Lee": "312-555-0123"}

with open("contacts.json", "w", encoding="utf-8") as f:
    json.dump(contacts, f, ensure_ascii=False, indent=2)

with open("contacts.json", encoding="utf-8") as f:
    contacts = json.load(f)

That solves the contact book problem from lesson 6: load the file when the program starts and save it when the user quits.

3. You want to save a dictionary that contains lists and load it back exactly as it was. What do you use?

  • json.dump() to save and json.load() to read
  • A CSV file
  • print() with the dictionary

JSON keeps the structure of dictionaries and lists; CSV is for flat tables.

Real case: a CSV with broken rows

Real files contain mistakes: a blank price, a quantity typed as a word. If your program stops at the first bad row, it's useless for daily work. An office supply store in Raleigh, North Carolina, exports its sales from a spreadsheet; this program adds up the valid rows and records which ones need checking:

import csv

# 1) Create a sample CSV: sales at an office supply store in Raleigh, NC
rows = """date,product,quantity,price
2026-03-02,Spiral notebook,12,3.50
2026-03-02,Blue pens 12-pack,30,4.25
2026-03-03,Backpack,2,
2026-03-03,Colored pencils,5,6.99
2026-03-04,Ruler 12 in,three,1.50
"""
with open("store_sales.csv", "w", encoding="utf-8") as f:
    f.write(rows)

# 2) Process it without letting one bad row stop everything
total = 0
errors = []
with open("store_sales.csv", newline="", encoding="utf-8") as f:
    for line, row in enumerate(csv.DictReader(f), start=2):
        try:
            amount = int(row["quantity"]) * float(row["price"])
        except ValueError:
            errors.append(line)
            continue
        total += amount

print(f"Total of valid rows: ${total:,.2f}")
print(f"Check these lines: {errors}")
Total of valid rows: $204.45
Check these lines: [4, 6]

Three design decisions worth copying:

  • The try wraps only the line that can fail, not the whole loop, so you know exactly what broke.
  • continue skips the bad row and processing moves on: $42.00 + $127.50 + $34.95 = $204.45 in valid sales.
  • enumerate(..., start=2) numbers from 2 because line 1 is the header. That way "line 4" matches row 4 in Excel, and whoever checks finds the backpack with no price and the ruler with "three" instantly.

4. Your program shows FileNotFoundError: [Errno 2] No such file or directory: 'expenses.csv', but the file is sitting in your Downloads folder. What's the most likely cause?

  • The CSV file is corrupted
  • You forgot encoding="utf-8"
  • The program runs from a different folder and looks for the file there

A path like "expenses.csv" is relative to the folder you run the program from. Put the CSV next to your .py file and run it from that folder, or use the full path. An encoding problem would raise UnicodeDecodeError, not FileNotFoundError.

Intermediate project: a personal expense tracker

This program brings together everything so far: variables, conditionals, loops, dictionaries, functions, files and error handling. It records expenses, shows a summary by category and saves everything to expenses.csv.

import csv
from datetime import date
from pathlib import Path

FILE = Path("expenses.csv")
FIELDS = ["date", "category", "description", "amount"]


def load_expenses():
    """Read the CSV and return a list of dictionaries."""
    if not FILE.exists():
        return []
    with open(FILE, newline="", encoding="utf-8") as f:
        expenses = list(csv.DictReader(f))
    for e in expenses:
        e["amount"] = float(e["amount"])
    return expenses


def save_expense(expense):
    """Append one expense to the CSV (creating the file if needed)."""
    is_new = not FILE.exists()
    with open(FILE, "a", newline="", encoding="utf-8") as f:
        writer = csv.DictWriter(f, fieldnames=FIELDS)
        if is_new:
            writer.writeheader()
        writer.writerow(expense)


def ask_amount(prompt):
    while True:
        try:
            value = float(input(prompt).strip().replace("$", "").replace(",", ""))
            if value > 0:
                return value
            print("The amount must be greater than 0.")
        except ValueError:
            print("Please enter a valid number.")


def add_expense():
    category = input("Category (food, transportation, housing...): ").strip().lower() or "other"
    description = input("Description: ").strip()
    amount = ask_amount("Amount: $")
    expense = {"date": date.today().isoformat(), "category": category,
               "description": description, "amount": amount}
    save_expense(expense)
    print("✓ Expense saved")


def summary(expenses):
    if not expenses:
        print("No expenses yet.")
        return
    total = sum(e["amount"] for e in expenses)
    by_category = {}
    for e in expenses:
        by_category[e["category"]] = by_category.get(e["category"], 0) + e["amount"]

    print(f"\n{'Category':16}{'Amount':>10}{'%':>8}")
    print("-" * 34)
    for cat, amount in sorted(by_category.items(), key=lambda x: x[1], reverse=True):
        print(f"{cat:16}{amount:10,.2f}{amount / total:8.1%}")
    print("-" * 34)
    print(f"{'TOTAL':16}{total:10,.2f}")
    biggest = max(expenses, key=lambda e: e["amount"])
    print(f"\nBiggest expense: {biggest['description']} (${biggest['amount']:,.2f})")


def main():
    while True:
        option = input("\n1) Add expense  2) View summary  3) Quit: ").strip()
        if option == "1":
            add_expense()
        elif option == "2":
            summary(load_expenses())
        elif option == "3":
            print("See you next time!")
            break
        else:
            print("Invalid option")


if __name__ == "__main__":
    main()

Put the practice file expenses.csv in the same folder, choose option 2, and you'll see this summary of the 40 sample expenses:

Category            Amount       %
----------------------------------
food                739.41   42.3%
housing             404.31   23.1%
entertainment       311.98   17.9%
transportation      291.38   16.7%
----------------------------------
TOTAL             1,747.08

Biggest expense: Electric bill ($128.37)
Practice file expenses by category Food $739.41 (42.3%), housing $404.31 (23.1%), entertainment $311.98 (17.9%) and transportation $291.38 (16.7%), out of $1,747.08 in total. $1,747 40 expenses
  • Food (42.3%) $739.41Groceries, lunches and coffee shops: 18 of the 40 expenses.
  • Housing (23.1%) $404.31Electric bills, internet and cleaning supplies.
  • Entertainment (17.9%) $311.98
  • Transportation (16.7%) $291.38
The percentages match what the summary() function calculates with the :8.1% format.

What you just used

  • pathlib.Path to check whether the file exists.
  • or "other": if the user leaves the category blank, a default is used (an empty string is falsy).
  • Column formatting with f-strings: {cat:16} reserves 16 characters, {amount:10,.2f} right-aligns with commas and {x:8.1%} shows a percentage.
  • sorted(..., key=..., reverse=True) to rank categories from highest to lowest spending.

Common mistakes with files and exceptions

If you open a CSV that Excel saved on Windows in its older encoding, a single "é" breaks the read:

UnicodeDecodeError: 'utf-8' codec can't decode byte 0xe9 in position 14: invalid continuation byte

Byte 0xe9 is "é" in the Windows encoding (cp1252). Open it with encoding="cp1252" or, better, re-save the file from Excel as "CSV UTF-8."

Writing except: pass "so it doesn't crash."

The program stops crashing but silently gives wrong results. Catch the specific error and report or log it.

Opening your history file with "w".

To add records use "a"; "w" erases everything.

Adding up CSV values as they arrive.

Everything arrives as text: convert with float() or you'll see TypeError: unsupported operand type(s) for +: 'int' and 'str'.

Forgetting newline="" when writing a CSV.

On Windows you'd get blank rows between records. Always use it with the csv module.

Challenges to improve the project

  1. Add an option to see only the current month's expenses (hint: compare e["date"][:7] with date.today().isoformat()[:7]).
  2. Define a budget per category in a dictionary and warn when a category goes over.
  3. Add an option to delete the last expense (you'll have to rewrite the whole file).
  4. Install pandas (pip install pandas) and build the summary with pd.read_csv("expenses.csv").groupby("category")["amount"].sum().
See solution
def this_months_expenses(expenses):
    current_month = date.today().isoformat()[:7]   # "2026-11"
    return [e for e in expenses if e["date"][:7] == current_month]

# In the menu:
# elif option == "4":
#     summary(this_months_expenses(load_expenses()))

This solves challenge 1. Comparing the first 7 characters works because ISO dates always start with "YYYY-MM."

Summary

  • try/except catches specific errors; else and finally complete the structure.
  • Always open files with with and encoding="utf-8"; use "a" to append and "w" only to overwrite.
  • Use CSV for tables (csv module) and JSON for structured data (json module).
  • Wrap only the risky line in try and use continue to skip bad rows.
  • The expense tracker combines everything you've learned into a real program.

You've completed the Intermediate level! The Advanced level starts now.

Next lesson: Python Classes and Objects: Object-Oriented Programming. You'll turn your expense tracker's dictionaries into classes with their own attributes and methods.

🎓 Save your progress and get a free certificate

Create your account in 30 seconds, mark your lessons and download your certificate at the end.

Enroll for free