Python Programming — Alpha School

Lesson 6: File Handling and Exceptions

🎧 Listen to this lesson

A program that forgets everything when it closes is a toy. Files give programs memory: save data to disk and it survives between runs. This lesson covers reading and writing text files with the with pattern, the file modes that can accidentally erase your data, saving structured data with JSON, and exceptions — Python's system for reporting failures — with try/except so a missing file or a typo at the keyboard becomes a polite message instead of a crash.

Writing and reading text files

The standard pattern opens a file, uses it, and lets with close it automatically — even if an error occurs mid-way:

with open("notes.txt", "w", encoding="utf-8") as f:
    f.write("First line\n")
    f.write("Second line\n")

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

Output:

First line
Second line

Three details matter. The "w" is the mode — write — and it creates the file if needed. The \n inside the strings is a newline character: write adds nothing on its own, so without \n your lines would run together into one long line. And iterating over a file yields one line at a time (each still carrying its \n, which is why the loop strips it before printing). The modes:

ModeMeaningIf the file exists
"r"read (the default)read from the start
"w"writeerased and rewritten
"a"appendnew text added at the end
"x"createerror if it already exists

The "w" row deserves respect: opening an existing file for writing discards its contents immediately, before you write a single character. Continuing with the same notes.txt, appending and then inspecting the whole file:

with open("notes.txt", "a", encoding="utf-8") as f:
    f.write("Third line\n")

with open("notes.txt", encoding="utf-8") as f:
    lines = f.readlines()
print("Lines:", len(lines))
print("Last:", lines[-1].strip())

Output: Lines: 3 and Last: Third line. readlines() returns a list of the lines; the companion read() returns the entire file as one string — pick per line when you will process line by line, whole-string when you will search or split it yourself.

Exceptions: planning for things going wrong

Files go missing. Users type letters where numbers belong. Python reports each failure by raising an exception, and try/except lets you handle it:

try:
    age = int(input("Age: "))
    print(f"Next year: {age + 1}")
except ValueError:
    print("Please enter a whole number.")

Type fifteen and the int() conversion raises ValueError; Python jumps straight to the matching except block and prints Please enter a whole number. — no crash, no traceback. Different failures raise different exceptions, and you can stack handlers:

try:
    result = 10 / int(input("Divide 10 by: "))
    print("Result:", result)
except ValueError:
    print("That was not a number.")
except ZeroDivisionError:
    print("You cannot divide by zero.")

Type 0 and the second handler prints You cannot divide by zero. Catch specific exceptions, as here — a bare except: swallows everything, including genuine bugs in your own code, and hides them from you (Lesson 7's worst enemy). A finally block runs whether or not an exception occurred, which is where cleanup lives when you manage a resource by hand:

f = open("notes.txt", encoding="utf-8")
try:
    print("First line:", f.readline().strip())
finally:
    f.close()

This prints First line: First line — and the finally guarantees the close. The with statement from the top of the lesson exists precisely so you rarely write this by hand.

Saving structured data with JSON

Text files store strings; programs think in lists and dictionaries. JSON bridges the two: json.dump writes Python data as text, json.load reads it back as data.

import json

scores = [85, 92, 78]
with open("scores.json", "w", encoding="utf-8") as f:
    json.dump(scores, f)

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

Output: [85, 92, 78] then True. JSON handles lists, dictionaries, strings, numbers, booleans and None — the data shapes of Lessons 2 and 5 — which is why it is the default save format for small apps (and the format APIs speak in Lesson 13). It does not handle sets or custom objects directly; those need converting first.

Worked example 1 — the load-or-start-fresh pattern

Every app that remembers anything needs this exact function: load saved data, and if there is none yet, start empty instead of crashing.

import json

def load_scores(path="best.json"):
    """Load a saved score list; start empty if there is no valid file."""
    try:
        with open(path, encoding="utf-8") as f:
            return json.load(f)
    except FileNotFoundError:
        return []
    except json.JSONDecodeError:
        return []

scores = load_scores()
scores.append(1200)
with open("best.json", "w", encoding="utf-8") as f:
    json.dump(scores, f)
print(load_scores())

Output: [1200]. Run it in a fresh folder: the first load_scores() finds no best.json, catches FileNotFoundError, and returns []; the program appends a score, saves, and the second call proves the round trip by reading [1200] back. The second handler earns its place too — if the file exists but is corrupted (half-written when the power cut, edited badly by hand), json.load raises JSONDecodeError, and starting fresh beats crashing. This one function is the skeleton of the study planner in Lesson 11 and the finance tracker in Lesson 20.

Worked example 2 — counting a file's lines, words and characters

File code plus string code plus arithmetic: a tiny text-analysis tool.

text = "Roses are red\nViolets are blue\nPython is fun\nAnd so are you\n"
with open("poem.txt", "w", encoding="utf-8") as f:
    f.write(text)

with open("poem.txt", encoding="utf-8") as f:
    content = f.read()

print("Lines:", len(content.splitlines()))
print("Words:", len(content.split()))
print("Characters:", len(content))

Output:

Lines: 4
Words: 13
Characters: 60

Check the character count by hand once: the four lines are 14, 17, 14 and 15 characters including each trailing newline — 60 in total. splitlines() counts lines without keeping the newline characters, while len(content) counts every character, newlines included; the difference between those two numbers is exactly the kind of detail file code forces you to think about. Word counting is just Lesson 3's split() applied to the whole file at once.

Key idea. Key idea: Open files with with, choose the mode deliberately ("w" erases!), and wrap risky operations in try/except with specific exception types — FileNotFoundError and JSONDecodeError turn "crash on first run" into "start fresh".

Common mistakes

  • Opening with "w" when you meant "a". The file is emptied the moment it opens. If data "disappeared", check the mode letter before anything else.
  • A bare except: that hides your own bugs. except: catches NameError and TypeError from your typos too, turning debuggable crashes into silent wrong behaviour. Name the exceptions you actually expect.
  • Assuming the file exists. First runs have no save file. Any open(path) for reading belongs near a FileNotFoundError plan.
  • Forgetting \n when writing. f.write("a"); f.write("b") produces ab on one line. write never adds newlines for you.
  • Saving data JSON cannot represent. json.dump({1, 2, 3}, f) raises TypeError: Object of type set is not JSON serializable. Convert sets to lists first.
Try it. Try it: Write code that appends the line "done" to a file log.txt without erasing what is already there, then prints how many lines the file now has. Which exception appears if you run int("abc") unprotected? (Answer: ValueError.)

Real-World Challenge

The task. Build a diary program with a memory: it stores entries (date plus text) in a JSON file, adds new entries, and prints all entries numbered. It must survive being run in a folder where the file does not exist yet.

Worked solution.

import json
from datetime import date

DIARY_FILE = "diary.json"

def load_entries():
    """Return the saved diary entries, or [] on first run / bad file."""
    try:
        with open(DIARY_FILE, encoding="utf-8") as f:
            return json.load(f)
    except (FileNotFoundError, json.JSONDecodeError):
        return []

def add_entry(text):
    entries = load_entries()
    entries.append({"date": str(date.today()), "text": text})
    with open(DIARY_FILE, "w", encoding="utf-8") as f:
        json.dump(entries, f, indent=2)

def show_entries():
    entries = load_entries()
    for i, e in enumerate(entries, 1):
        print(f"{i}. [{e['date']}] {e['text']}")

add_entry("Started learning file handling.")
add_entry("Wrote my first diary program.")
show_entries()

How it works. The three functions divide the labour exactly as Lesson 4 recommends: load_entries owns the file-reading risk (note the tuple in except (FileNotFoundError, json.JSONDecodeError) — one handler covering both ways loading can fail), add_entry owns the load-append-save cycle, and show_entries owns display, using enumerate(entries, 1) so numbering starts at 1 for humans. Each entry is a dictionary with a fixed shape — "date" and "text" keys every time — and str(date.today()) stamps it in ISO format like 2026-10-21, so running the program prints the two entries numbered, each stamped with the date you ran it on. indent=2 in the dump call pretty-prints the JSON, so you can open diary.json in an editor and read your own diary — readable save files are dramatically easier to debug.

Extension. Add a search_entries(keyword) function that prints only entries whose text contains the keyword (case-insensitive — Lesson 3's lower() on both sides). Test it with the keyword "diary" (matches entry 2) and "FILE" (matches entry 1 once case is normalised), then write a third entry and confirm the counts still behave after a fresh load.

Going further — appending to a log, and timestamps

Writing creates and truncates; mode "a" appends, which is how logs, journals and diaries grow without erasing history. Pair append mode with a timestamp and you have the skeleton of every audit trail and activity feed ever built:

from datetime import datetime

stamp = datetime(2026, 10, 22, 9, 30, 0).strftime("%Y-%m-%d %H:%M:%S")
with open("log.txt", "a", encoding="utf-8") as f:
    f.write(f"{stamp} - program started\n")
    f.write(f"{stamp} - program started\n")
print(open("log.txt", encoding="utf-8").read())

Output:

2026-10-22 09:30:00 - program started
2026-10-22 09:30:00 - program started

Three details carry the professional weight. The example stamps a fixed moment so its output is reproducible in print; real programs take datetime.now() at the instant of writing, and their logs therefore record *when* things happened — the first question every investigation asks. The strftime format (%Y-%m-%d %H:%M:%S) produces ISO-style sortable stamps, the same chronological-string property Lessons 11 and 13 rely on. And the file is written with explicit newline characters inside the strings, because text mode adds none for you — forgetting them is why beginner log files arrive as one endless line. One more tool belongs in this kit: the pathlib module's Path objects wrap these same operations (Path("log.txt").read_text(), .write_text(), .exists()) in a cleaner object style that much modern code prefers.

Practice corner — pathlib, the modern file style

Everything in this lesson also exists in object form in the pathlib module, and modern code increasingly prefers it: a Path knows its own operations, joins with /, and answers questions about itself:

from pathlib import Path

p = Path("notes.txt")
p.write_text("line one\nline two\n", encoding="utf-8")
print(p.read_text(encoding="utf-8").splitlines())
print(p.exists(), p.stat().st_size)

Output:

['line one', 'line two']
True 18

write_text/read_text handle one-shot whole-file jobs in a single call (still pass the encoding explicitly, as always), exists() replaces the try/except probe for simple checks, and stat().st_size reports the byte count — 18 here, nine per line including each newline. For anything beyond one-shot access — huge files, appending, structured formats — the with open(...) discipline of the main lesson remains the tool; pathlib is the express lane, not a replacement road.

🎬 Watch & Learn

Prefer video? These searches surface top tutorials on this lesson's topic:

💪 Practice Zone

Try these yourself — hints are included, answers are not. Run your code in any Python environment.

  1. Diary writer — Write three lines to diary.txt, then open it again and print its contents.
    HintFirst open("diary.txt", "w") to write, then open("diary.txt") to read. Use a with block.
  2. Safe divider — Ask for two numbers and print the division — but print Cannot divide by zero instead of crashing on 0.
    HintWrap the division in try: and catch ZeroDivisionError.
  3. Line counter — Create any text file, then write a program that prints how many lines it has.
    HintLoop for line in f: and count, or use len(f.readlines()).
  4. Missing file, no crash — Try to open nope.txt (which doesn't exist). Catch the error and print a friendly message.
    HintCatch FileNotFoundError — put the specific except before any general one.

🌍 Real World

Servers write log files recording every visit and error — companies analyze these files with Python scripts like yours to spot outages and attacks. Exception handling is what keeps real programs alive: when one bad record appears in a million-line log, a try/except skips it instead of crashing the analysis.

Keep reading to complete this lesson