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.
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.
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)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.
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.
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.
-
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)) # 55Check 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") -
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 excCheck 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") -
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.