Read Before You Change

The Course
•
September 18, 2026
•
8 min read

The beginner course taught you to write programs. This course is about the programs you’ll spend most of your time on: ones that already exist, work well enough that people depend on them, and need to change anyway.

All week you’ll grow one tool, a log analyzer. Servers write a line to a log for everything that happens, and someone always needs to know how many errors there were, and where. On Day 7 it will be a tested command-line tool that copes with messy input. Today it’s this:

report.py, as you found it Python
# report.py - counts what happened in a log. It works. Nobody dares touch it. f = open("sample.log") data = f.read() f.close() c = {} e = {} n = 0 for l in data.split("\n"): if l.strip() == "": continue n = n + 1 w = l.split() if w[2] in c: c[w[2]] = c[w[2]] + 1 else: c[w[2]] = 1 if w[2] == "ERROR": s = w[3][:-1] if s in e: e[s] = e[s] + 1 else: e[s] = 1 print("Lines read:", n) print("Levels:", c) print("Errors by source:", e) 2026-09-01 09:14:02 INFO auth: user=ada action=login 2026-09-01 09:14:07 WARNING payments: order=1042 amount=19.99 retry=1 2026-09-01 09:15:30 ERROR payments: order=1043 msg="card declined" 2026-09-01 09:16:11 INFO auth: user=alan action=logout 2026-09-01 09:17:45 ERROR search: query="python books" msg="index offline" 2026-09-01 09:18:02 INFO search: query=dune results=4

Run it. It works. That’s worth saying, because the rest of today is about changing it, and “it works” is the thing you mustn’t lose along the way.

How to use this course

Seven days is a pace, not a deadline. Each day is a module, and the project carries over, so taking a day over two sittings costs nothing.

Core and “Going further”. Sections headed Going further are optional: nothing later in the course depends on them. Skip them on a first pass.

Help comes in steps. Every exercise has hints before its solution — the idea first, then a first step. Open only as many as you need. The same goes for AI assistants: ask for an explanation or a hint rather than the finished code, check what you’re told, then do a similar task without it. (The beginner course’s first lesson says more about that routine.)

Ready? A quick check

Three tasks using only the beginner course. They’re a readiness check, not a test: if one stops you, the lesson named in its hint is worth a reread before Day 2.

The beginner course marks four lessons Explore — optional there. This course uses all four: Iterators & Generators and Inheritance on Day 2, Arguments in Depth on Day 4, and Dunder Methods on Day 5. If you skipped them, read each before the day that needs it.

1. Predict both lines.

Ready: predict Python
from collections import Counter

lines = ["ERROR db", "INFO web", "ERROR db", "WARN web", "ERROR api"]
levels = Counter(line.split()[0] for line in lines)
print(levels.most_common(1))

errors = sorted({line.split()[1] for line in lines if line.startswith("ERROR")})
print(errors)
Check your prediction
[('ERROR', 3)]
['api', 'db']

A generator expression feeding Counter, then a set comprehension that removes the duplicate db. From the beginner lessons on Comprehensions and Modules.

2. Fix. Count the status codes, skipping any line that isn’t a status and a path. It should print {'200': 2, '404': 1, '500': 1}, and it crashes.

Ready: fix Python
lines = ["200 /home", "404 /missing", "200 /about", "oops", "500 /api"]

counts = {}
for line in lines:
    status, path = line.split()
    counts[status] = counts.get(status, 0) + 1

print(counts)
Check your fix
lines = ["200 /home", "404 /missing", "200 /about", "oops", "500 /api"]

counts = {}
for line in lines:
    try:
        status, path = line.split()
    except ValueError:
        continue
    counts[status] = counts.get(status, 0) + 1

print(counts)
{'200': 2, '404': 1, '500': 1}

"oops" splits into one word, and unpacking one value into two names raises ValueError. Catch the specific error and skip the line. From the beginner Errors & Exceptions lesson — and Day 2 of this course is about doing this properly.

3. Write top(counts, n): the n keys with the highest counts, ties in alphabetical order. This should print ['api', 'db']:

Ready: write Python
# def top(counts, n): ...

print(top({"db": 3, "api": 3, "web": 1}, 2))
Check your answer
def top(counts, n):
    return sorted(counts, key=lambda key: (-counts[key], key))[:n]

print(top({"db": 3, "api": 3, "web": 1}, 2))
['api', 'db']

A tuple key — count descending, then name — and a slice. From the beginner Scope & Lambdas lesson.

What’s wrong with code that works

Nothing, until someone has to change it. Say you’re asked to count warnings per source as well as errors, or to read a different file, or to check that the error count is right. Try to do any of those in your head, and notice what gets in the way:

  • The names say nothing. c, e, w, s: you have to run the whole loop in your head to find out that c is “count per level”.
  • Everything happens at once. Reading the file, splitting lines, counting and printing are all tangled in one loop. You can’t check the counting without also reading a file and printing.
  • Nothing can be tested on its own. There are no functions, so there’s nothing to call with a small input and check the answer.
  • The file name is baked in. It reads sample.log and only sample.log.

None of these is a bug. They are all reasons the next change is likely to add one.

Say what it does, precisely

Before changing code, write down what it does. Not what it’s meant to do: what it does. For this script:

  1. Reads sample.log, and ignores lines that are empty or only whitespace.
  2. Counts the lines it didn’t ignore.
  3. Counts lines per level, where the level is the third whitespace-separated word, in the order each level first appears.
  4. For ERROR lines, counts per source, where the source is the fourth word with its last character removed.
  5. Prints three lines, in that order, in that exact format.

Point 4 is worth a second look. w[3][:-1] removes the last character, whatever it is. The author assumed it’s always the colon in payments:. That’s true for every line in today’s file. Whether it’s true for every log you’ll ever see is a question for later in the week. Today’s rule is stricter: don’t change behaviour while restructuring. Notice it, write it down, and keep going.

Two kinds of change, never at once

A refactor changes how the code is organised and leaves what it does exactly the same. A fix or a feature changes what it does. Do them as separate steps. If you do both at once and the output changes, you can’t tell which of the two changed it.

Ask the code questions

Reading is faster when you run small experiments instead of simulating the code in your head. What does w look like for one line? What does w[3][:-1] do to a word that has no colon?

Poke at one line Python
line = '2026-09-01 09:15:30 ERROR   payments: order=1043 msg="card declined"'
w = line.split()
print(w)
print(w[2], "|", w[3], "|", w[3][:-1])

print("payments"[:-1])      # no colon: the last letter goes instead

A few lines like this answer questions that would take minutes of careful reading. Treat the interpreter as a way of reading code, not only a way of running it.

Today’s plan

You’ll change this script without changing what it prints, in three steps:

  1. Build a safety net. A test that fails if the output changes at all.
  2. Extract functions, one at a time, running the test after each step.
  3. Test the pieces. Now that there are functions, give each one tests of its own with small inputs.

At the end of the day the report prints exactly what it prints now, and every part of it can be tested and changed on its own. That’s what Day 2 builds on.

Exercise

Read it like a reviewer

Without running anything, predict what the script prints if sample.log contains only these two lines:

2026-09-01 10:00:00 ERROR   db: timeout
2026-09-01 10:00:05 ERROR   db:timeout

Then check your prediction by editing sample.log in the playground at the top of the page and running it. (Reset puts the original file back.)

Hint 1: the idea

You don’t need to run anything: the numbered list in Say what it does, precisely describes the script exactly. Apply it to each line by hand. Point 4 is the one that decides the answer.

Hint 2: a first step

Write down the fourth whitespace-separated word of each line. Look closely at the second line: there’s no space after the colon. Then apply [:-1] to each word — it removes the last character, whatever that character is.

Show one solution
Lines read: 2
Levels: {'ERROR': 2}
Errors by source: {'db': 1, 'db:timeou': 1}

The second line has no space after the colon, so its fourth word is db:timeout, and [:-1] turns that into db:timeou. Same service, counted twice, under a name that doesn’t exist. That’s point 4 from the list above, showing up. It goes on your list of things to fix. It isn’t fixed during the refactor, because a refactor mustn’t alter what the script does. Lesson 4 makes one deliberate fix once the refactor is done. This one waits for Day 2, which replaces the whole line-splitting approach, and the bug goes with it.

What you can now do

  • Separate “what’s wrong with this code” from “what’s wrong with what it does”.
  • Describe precisely what existing code does, including the parts that look like accidents.
  • Use small experiments in the interpreter to read code faster.
  • Keep restructuring and behaviour changes as separate steps.

Next: a test that will tell you, instantly, if anything you do changes the output.

Last updated on September 18, 2026

Was this article helpful?

Your response is saved on this device.