Kiru Lab  /  Foundations: Python as an Instrument  /  Python From First Principles

Files, and Handling Failure

Reading data off disk, and deciding on purpose what your program does when something goes wrong.

Code Walkthrough  ·  about 35 minutes

Everything you analyze comes from somewhere — a file, a download, an API. Reading a file in Python is three lines, and the `with` statement is what guarantees the file gets closed even if something fails partway through.

Reading and writing
with open("results.txt") as f:
    contents = f.read()              # the whole file as one string

with open("results.txt") as f:
    for line in f:                   # one line at a time — works on huge files
        print(line.strip())          # strip() removes the trailing newline

with open("output.txt", "w") as f:  # "w" overwrites, "a" appends
    f.write("loss: 0.031\n")

Structured data

Two formats cover most of what you will meet: CSV for tables and JSON for nested records. Both have a module in the standard library, so neither needs an install.

CSV and JSON
import csv, json

with open("data.csv", newline="") as f:
    for row in csv.DictReader(f):        # each row becomes a dict
        print(row["score"])              # values arrive as strings — convert them

with open("config.json") as f:
    config = json.load(f)                # becomes a dict

with open("results.json", "w") as f:
    json.dump({"seed": 0, "loss": 0.031}, f, indent=2)
The CSV trap

Every value read from a CSV is a string, including numbers. `row["score"] + 1` will raise a TypeError, and `sorted()` on string numbers puts "10" before "9". Convert on the way in, and you avoid a whole category of confusing results later.

Handling failure deliberately

A `try`/`except` block lets you decide what happens when an operation fails instead of letting the program stop. The important word is *decide* — catching an error and doing nothing is worse than crashing, because it converts a loud failure into a silent wrong answer.

Catching narrowly
try:
    with open("config.json") as f:
        config = json.load(f)
except FileNotFoundError:
    config = {"seed": 0}                 # a sensible default, chosen on purpose
except json.JSONDecodeError as exc:
    raise ValueError(f"config.json is malformed: {exc}") from exc
  • Catch the specific exception you expect, never a bare `except:` — that swallows typos in your own code along with everything else.
  • Handle it or re-raise it. Logging and continuing with bad data is how a small problem becomes an unexplainable result three hours later.
  • Raise your own errors when an input is invalid. A clear failure at the boundary beats a confusing one deep inside.
The connection to everything after

This is the same principle as the guard clause from the functions lesson and the same one behind the confabulation argument in track five: a system that returns a plausible answer instead of signaling that it cannot answer has converted a detectable failure into an undetectable one. Notice how often that idea recurs.


Hold on to

  • `with` guarantees the file closes, even on failure
  • CSV values arrive as strings — convert them at the boundary
  • Catch narrowly, then handle or re-raise; never swallow silently

Work through

Try each one before opening the solution. Getting it wrong first is most of where the learning happens.

  1. Write a program that writes ten numbers to a file, one per line, then reads them back, converts them to integers, and prints the sum.
    Hint

    Write with `"\n"` after each number; read back with a loop and `int()`.

    Solution

    The conversion on the way back in is the part that matters — the file contains text, so without `int()` you would be summing strings and get a TypeError, or concatenating them if you had used `+` on strings.

    with open("numbers.txt", "w") as f:
        for n in range(1, 11):
            f.write(f"{n}\n")
    
    with open("numbers.txt") as f:
        numbers = [int(line.strip()) for line in f if line.strip()]
    
    print(sum(numbers))    # 55
    Check your work

    Paste this after your own code. If it runs without raising, you have it.

    assert numbers == list(range(1, 11))
    assert sum(numbers) == 55
    print("ok")
  2. Write `load_config(path)` that returns a default dict when the file is missing but raises a clear ValueError when the file exists and is malformed. Test both paths.
    Hint

    Two different exception types need two different responses.

    Solution

    A missing file is an expected condition with a sensible default. Malformed JSON is not — it means someone edited the file wrongly, and silently substituting a default would hide that. Catch them separately and treat them differently; that distinction is the entire skill.

    import json
    
    DEFAULT = {"seed": 0}
    
    def load_config(path):
        try:
            with open(path) as f:
                return json.load(f)
        except FileNotFoundError:
            return dict(DEFAULT)
        except json.JSONDecodeError as exc:
            raise ValueError(f"{path} is malformed: {exc}") from exc
    Check your work

    Paste this after your own code. If it runs without raising, you have it.

    assert load_config("does-not-exist.json") == {"seed": 0}
    
    with open("bad.json", "w") as f:
        f.write("{not json")
    try:
        load_config("bad.json")
    except ValueError as exc:
        assert "malformed" in str(exc)
    else:
        raise AssertionError("expected a ValueError")
    print("ok")
  3. Read a CSV where a numeric column is stored as text. Sort by that column as strings and as numbers, and show that the two orderings differ.
    Hint

    Sort the same column twice — once as it arrives, once after converting.

    Solution

    String sorting is lexicographic, so "10" comes before "9" because "1" precedes "9" character by character. Numeric sorting puts 9 first. This is one of the most common silent data bugs there is, and it produces plausible-looking output, which is what makes it dangerous.

    import csv, io
    
    raw = "name,score\na,9\nb,10\nc,100\n"
    rows = list(csv.DictReader(io.StringIO(raw)))
    
    as_text = sorted(rows, key=lambda r: r["score"])
    as_number = sorted(rows, key=lambda r: int(r["score"]))
    
    print([r["score"] for r in as_text])     # ['10', '100', '9']
    print([r["score"] for r in as_number])   # ['9', '10', '100']
    Check your work

    Paste this after your own code. If it runs without raising, you have it.

    assert [r["score"] for r in as_text] == ["10", "100", "9"]
    assert [r["score"] for r in as_number] == ["9", "10", "100"]
    print("ok")

Sign in to track your progress through the lab.