Rejecting Bad Input

The Course
•
September 18, 2026
•
12 min read

Real log files are not tidy. Log rotation writes a marker line, one service logs its times without seconds, and a crash leaves half a line behind. Here is what Day 1’s level_of makes of three of them:

Day 1's code meets junk Python
def level_of(line):
    """Day 1: the level is the third word of a line."""
    return line.split()[2]


junk = [
    "--- rotated at 09:16 ---",
    "2026-09-01 9:18 INFO search: query=dune results=4",
    "garbage",
]
for line in junk:
    print(repr(level_of(line)))

The crash on the last line isn’t the worst part. The first two lines didn’t crash at all. The rotation marker has the level 'at', and the second line comes out as 'INFO', which looks right but only by accident: the time is malformed, and nothing noticed. A program that crashes on bad input is annoying. A program that quietly accepts it is wrong.

Decide what a bad line means

Before writing any code, decide what the program should do with a bad line. There are two reasonable answers:

StrategyWhen it fits
Fail fast: stop at the first bad lineThe input should be perfect, e.g. a config file. A bad line means something upstream is broken.
Skip and report: carry on, but keep every bad line and say so at the endThe input is expected to be a bit messy, e.g. logs. One bad line shouldn’t lose a day’s report.

A log analyzer should skip and report. Whichever you choose, one thing is never acceptable: skipping without reporting. A count that silently leaves out 5% of the lines is worse than no count, because someone will believe it.

Both strategies start from the same place. parse_record checks the line and raises a precise error when it’s wrong. The caller decides whether that error stops the program or gets collected.

An exception that says what went wrong

ValueError("bad line") would work, but the caller can’t do much with it. An error for bad input should carry what the caller needs: which line, why it was rejected, and the line itself.

ParseError, and four ways a line can be wrong Python
from datetime import datetime

LEVELS = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}


class ParseError(ValueError):
    """A line that is not a valid log record."""

    def __init__(self, line_number, reason, line):
        super().__init__(f"line {line_number}: {reason}: {line!r}")
        self.line_number = line_number
        self.reason = reason
        self.line = line


def check_prefix(line, line_number=0):
    parts = line.split(maxsplit=3)
    if len(parts) < 4:
        raise ParseError(line_number, "too few parts", line)
    date, time, level, rest = parts
    try:
        datetime.fromisoformat(f"{date} {time}")
    except ValueError:
        raise ParseError(line_number, "bad timestamp", line) from None
    if level not in LEVELS:
        raise ParseError(line_number, f"unknown level {level!r}", line)
    if ":" not in rest:
        raise ParseError(line_number, "missing 'source:'", line)
    return "ok"


lines = [
    "2026-09-01 09:14:02 INFO    auth: user=ada",
    "garbage",
    "2026-09-01 9:18 INFO search: query=dune",
    "2026-09-01 09:20:00 LOUD    auth: user=ada",
    "2026-09-01 09:21:00 INFO    no colon anywhere",
]
for number, line in enumerate(lines, start=1):
    try:
        print(check_prefix(line, number))
    except ParseError as error:
        print(error, "| reason:", error.reason)

Four design choices are worth copying:

  • It subclasses ValueError. “The value you gave me is wrong” is exactly what ValueError means, so code that already catches ValueError keeps working. Code that wants only parse errors can catch ParseError.
  • Its data are attributes, not just text. The message is for people. reason and line_number are for code, like a test that checks why a line was rejected, or a report that groups bad lines by reason.
  • It checks in order, cheapest first. Each check can then assume the ones before it passed. There’s no point parsing a timestamp from a line with two words.
  • from None hides the internal error. Without it, the traceback would show fromisoformat’s own ValueError first and then “During handling of the above exception, another exception occurred”. That’s noise here, because ParseError already says everything. Use raise ... from error instead when the original cause is useful to the reader.

Testing the bad cases

Each kind of bad line needs a test, and they’re all the same test with different data. subTest runs one test body over a table of cases and reports each failure on its own, so one broken case doesn’t hide the rest:

def test_each_kind_of_bad_line_is_reported(self):
    cases = {
        "garbage": "too few parts",
        "2026-13-01 09:00:00 INFO    a: x=1": "bad timestamp",
        "2026-09-01 09:00:00 LOUD    a: x=1": "unknown level 'LOUD'",
        "2026-09-01 09:00:00 INFO    no colon here": "missing 'source:'",
    }
    for line, reason in cases.items():
        with self.subTest(line=line):
            with self.assertRaises(ParseError) as caught:
                parse_record(line, 7)
            self.assertEqual(caught.exception.reason, reason)
            self.assertEqual(caught.exception.line_number, 7)

assertRaises used as a with block catches the exception, and caught.exception lets you check its attributes afterwards. Checking only that some ParseError was raised would pass even if the line was rejected for the wrong reason.

Bug hunt: the numbers that looked fine

The analyzer’s parse_lines collects bad lines into a list the caller passes in. Someone wrote the report below, and it looks perfectly healthy. Run it, then look in messy.log:

A report that looks fine Python
"""The report as someone first wrote it.""" from analyzer import parse_lines, read_lines, summarize levels, error_sources = summarize(parse_lines(read_lines("messy.log"), [])) print("Records counted: ", sum(levels.values())) print("Levels: ", dict(levels)) print("Errors by source:", dict(error_sources)) """Log analyzer, end of Day 2: lines become records, streamed and validated.""" import re from collections import Counter from dataclasses import dataclass, field from datetime import datetime LEVELS = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"} # key=value, where a value is either "quoted, spaces allowed" or a single word. FIELD = re.compile(r'(?P<key>\w+)=(?P<value>"[^"]*"|\S+)') @dataclass(frozen=True) class LogRecord: timestamp: datetime level: str source: str message: str fields: dict[str, str] = field(default_factory=dict) line_number: int = 0 class ParseError(ValueError): """A line that is not a valid log record.""" def __init__(self, line_number, reason, line): super().__init__(f"line {line_number}: {reason}: {line!r}") self.line_number = line_number self.reason = reason self.line = line def parse_fields(text): """Turn 'a=1 b="two words"' into {'a': '1', 'b': 'two words'}.""" return {m["key"]: m["value"].strip('"') for m in FIELD.finditer(text)} def parse_record(line, line_number=0): """Parse one line, or raise ParseError saying what is wrong with it.""" parts = line.split(maxsplit=3) if len(parts) < 4: raise ParseError(line_number, "too few parts", line) date, time, level, rest = parts try: timestamp = datetime.fromisoformat(f"{date} {time}") except ValueError: raise ParseError(line_number, "bad timestamp", line) from None if level not in LEVELS: raise ParseError(line_number, f"unknown level {level!r}", line) source, colon, message = rest.partition(":") if not colon: raise ParseError(line_number, "missing 'source:'", line) message = message.strip() return LogRecord(timestamp, level, source, message, parse_fields(message), line_number) def read_lines(path): """Yield the lines of a file one at a time, without line endings.""" with open(path) as file: for line in file: yield line.rstrip("\n") def parse_lines(lines, errors): """Yield a record per good line; add a ParseError to the errors list per bad one.""" for number, line in enumerate(lines, start=1): if not line.strip(): continue try: yield parse_record(line, number) except ParseError as error: errors.append(error) def summarize(records): """Count levels and errors per source in a single pass.""" levels, error_sources = Counter(), Counter() for record in records: levels[record.level] += 1 if record.level == "ERROR": error_sources[record.source] += 1 return levels, error_sources 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 --- rotated at 09:16 --- 2026-09-01 09:17:45 ERROR search: query="python books" msg="index offline" 2026-09-01 9:18 INFO search: query=dune results=4 2026-09-01 09:19:30 ERROR auth: user=grace msg="token expired"

Reproduce. The report says it counted 6 records. messy.log has 8 non-blank lines. Nothing crashed, and nothing printed a warning.

Inspect. Where did two lines go? parse_lines doesn’t raise for a bad line. It appends a ParseError to errors and moves on. So the relevant state is the errors list, and main.py passes []: a brand-new list that nothing else can see. The errors were collected, then thrown away with the list.

Narrow. Give the list a name and print it:

errors = []
levels, error_sources = summarize(parse_lines(read_lines("messy.log"), errors))
print(errors)

There they are: the rotation marker, and the line with the short time.

Pin it. Write a test for the rule itself rather than for this one file: every non-blank line becomes either a record or an error.

def test_every_line_is_accounted_for(self):
    lines = ["garbage", "", "2026-09-01 09:00:00 INFO    a: x=1", "   "]
    errors = []
    records = list(parse_lines(lines, errors))
    non_blank = [line for line in lines if line.strip()]
    self.assertEqual(len(records) + len(errors), len(non_blank))

Fix. Keep the list and report it. That’s the Day 2 reference below.

An API that makes the mistake easy

Passing [] was allowed by the design, and that’s a design smell. On Day 4 you’ll replace the list with an on_error function, so the caller has to decide what happens to a bad line instead of being able to forget.

Two tools for the checkpoint

The checkpoint parses a different kind of log, and it needs two tools the week hasn’t used yet. Try them here first.

Matching a whole line. re has three ways to apply a pattern, and they differ in how much of the text the pattern must cover:

search, match and fullmatch Python
import re

STATUS = re.compile(r"(?P<code>\d{3}) (?P<size>\d+)")

for text in ["200 5120", "200 5120 and then junk", "junk, then 200 5120"]:
    print(f"{text!r:28}",
          "search:", bool(STATUS.search(text)),       # anywhere in the text
          "match:", bool(STATUS.match(text)),         # starting at the beginning
          "fullmatch:", bool(STATUS.fullmatch(text))) # the whole text, nothing else

When a line must be a record, with nothing before or after it, use fullmatch. It returns None for anything else, which is exactly the signal for a ParseError.

Reading a timestamp in someone else’s format. datetime.fromisoformat only reads ISO dates. For any other layout, datetime.strptime(text, format) takes a format string that spells the layout out, one directive per part:

strptime Python
from datetime import datetime

when = datetime.strptime("01/Sep/2026:09:14:02 +0000", "%d/%b/%Y:%H:%M:%S %z")
print(when)
print(when.tzinfo)                     # %z read "+0000": this time knows its timezone

for bad in ["31/Feb/2026:09:14:02 +0000", "01/Sep/2026 09:14:02"]:
    try:
        datetime.strptime(bad, "%d/%b/%Y:%H:%M:%S %z")
    except ValueError as error:
        print("ValueError:", error)

%d is the day, %b the short month name, %Y the four-digit year, %H:%M:%S the time, and %z a UTC offset like +0000. Everything else in the format, like / and :, must appear literally. A regex can check that a date looks right. Only strptime knows that February has no 31st.

Day 2 checkpoint

Exercise

Parse a web server access log

A different format this time, the one most web servers write:

10.0.0.5 - - [01/Sep/2026:09:14:02 +0000] "GET /books?q=python HTTP/1.1" 200 5120

That’s the client IP, two fields nobody uses, the time in brackets, the request in quotes, the status code, and the response size in bytes, where a size of - means there was no body.

Write the three functions in access.py so all seven tests pass. The tests are the spec. Read them before you start, because they decide things like what a - size becomes and whether ?q=python is part of the path.

"""Day 2 checkpoint: parse a web server access log. The tests are the spec.""" import re from collections import Counter from dataclasses import dataclass from datetime import datetime # Write your pattern here. @dataclass(frozen=True) class Request: ip: str timestamp: datetime method: str path: str status: int size: int line_number: int = 0 class ParseError(ValueError): def __init__(self, line_number, reason, line): super().__init__(f"line {line_number}: {reason}: {line!r}") self.line_number = line_number self.reason = reason self.line = line def parse_access(line, line_number=0): """Parse one line into a Request, or raise ParseError.""" raise NotImplementedError("your code here") def parse_access_lines(lines, errors): """Yield a Request per good line; add a ParseError to the errors list per bad one.""" raise NotImplementedError("your code here") def busiest_paths(requests, n): """The n most requested paths, ignoring query strings.""" raise NotImplementedError("your code here") import unittest from datetime import datetime, timezone from access import ParseError, busiest_paths, parse_access, parse_access_lines GOOD = '10.0.0.5 - - [01/Sep/2026:09:14:02 +0000] "GET /books?q=python HTTP/1.1" 200 5120' class TestCheckpoint(unittest.TestCase): def test_a_good_line(self): r = parse_access(GOOD) self.assertEqual((r.ip, r.method, r.path), ("10.0.0.5", "GET", "/books?q=python")) self.assertEqual((r.status, r.size), (200, 5120)) self.assertEqual(r.timestamp, datetime(2026, 9, 1, 9, 14, 2, tzinfo=timezone.utc)) def test_no_body_size_is_zero(self): line = GOOD.replace("200 5120", "304 -") self.assertEqual(parse_access(line).size, 0) def test_not_an_access_line(self): with self.assertRaises(ParseError) as caught: parse_access("2026-09-01 09:14:02 INFO auth: user=ada", 3) self.assertEqual((caught.exception.reason, caught.exception.line_number), ("not an access log line", 3)) def test_trailing_junk_is_not_an_access_line(self): with self.assertRaises(ParseError): parse_access(GOOD + " and then some junk") def test_bad_timestamp(self): with self.assertRaises(ParseError) as caught: parse_access(GOOD.replace("01/Sep/2026", "31/Feb/2026")) self.assertEqual(caught.exception.reason, "bad timestamp") def test_bad_lines_are_kept_and_counted(self): errors = [] requests = list(parse_access_lines([GOOD, "nonsense", "", GOOD], errors)) self.assertEqual((len(requests), len(errors)), (2, 1)) def test_busiest_paths_ignore_the_query_string(self): lines = [GOOD, GOOD.replace("/books?q=python", "/books?q=dune"), GOOD.replace("/books?q=python", "/about")] requests = parse_access_lines(lines, []) self.assertEqual(busiest_paths(requests, 1), [("/books", 2)]) import sys import unittest import test_access suite = unittest.defaultTestLoader.loadTestsFromModule(test_access) unittest.TextTestRunner(stream=sys.stdout, verbosity=2).run(suite)
Hint 1: the shape of the pattern

Describe the line left to right, one piece per part, in a re.VERBOSE pattern: a non-space run (the IP), two more non-space runs, a bracketed part (\[ and \], since brackets mean something in a regex), a quoted part, then two numbers. Name the groups you need: ip, when, method, path, status, size. In verbose mode, spaces in the pattern are ignored, so write each real space as \s.

Hint 2: the tricky parts
  • Inside the brackets: [^\]]+, “anything but a closing bracket”.
  • Inside the quotes: the method is [A-Z]+, the path is \S+, and the protocol can be [^"]*.
  • The size is digits or a dash: \d+|-.
  • Give strptime the text of the when group, and turn its ValueError into a ParseError with the reason the test expects.
Show one solution
ACCESS = re.compile(
    r"""
    (?P<ip>\S+) \s \S+ \s \S+ \s          # 10.0.0.5 - -
    \[(?P<when>[^\]]+)\] \s               # [01/Sep/2026:09:14:02 +0000]
    "(?P<method>[A-Z]+) \s (?P<path>\S+) \s [^"]*" \s   # "GET /books HTTP/1.1"
    (?P<status>\d{3}) \s (?P<size>\d+|-)  # 200 5120
    """,
    re.VERBOSE,
)


def parse_access(line, line_number=0):
    match = ACCESS.fullmatch(line)
    if match is None:
        raise ParseError(line_number, "not an access log line", line)
    try:
        timestamp = datetime.strptime(match["when"], "%d/%b/%Y:%H:%M:%S %z")
    except ValueError:
        raise ParseError(line_number, "bad timestamp", line) from None
    size = 0 if match["size"] == "-" else int(match["size"])
    return Request(match["ip"], timestamp, match["method"], match["path"],
                   int(match["status"]), size, line_number)


def parse_access_lines(lines, errors):
    for number, line in enumerate(lines, start=1):
        if not line.strip():
            continue
        try:
            yield parse_access(line, number)
        except ParseError as error:
            errors.append(error)


def busiest_paths(requests, n):
    """The n most requested paths, ignoring query strings."""
    counts = Counter(request.path.partition("?")[0] for request in requests)
    return counts.most_common(n)

This time a regex is the right tool from the start. The brackets and the quoted request are exactly the structure split() can’t describe. Some details:

  • fullmatch, not match, is what makes the trailing-junk test pass.
  • [^\]]+ is the negated class from lesson 2, “anything but a closing bracket”.
  • %z makes the timestamp timezone-aware, which is why the test compares against tzinfo=timezone.utc.
  • int() on the numbers happens at the edge, like everything else in parsing. Nothing downstream should have to know a status arrived as text.
  • partition("?")[0] drops the query string, and Counter.most_common(n) sorts by count for you.
  • parse_access_lines is parse_lines from this lesson with a different parser. If you find yourself writing it a third time, that’s the moment to pass the parser in as an argument, which is where Day 4 starts.

The Day 2 reference solution

This is the analyzer at the end of the day, and where Day 3 starts. Run prints the report from messy.log, including the two lines it skipped, then runs all eight tests. Four of them are the regression tests from today’s bug hunts.

Day 2 reference: records, regex, streaming, errors Python
"""The Day 2 report: nothing is dropped without saying so.""" import sys import unittest import test_analyzer from analyzer import parse_lines, read_lines, summarize errors = [] levels, error_sources = summarize(parse_lines(read_lines("messy.log"), errors)) print("Levels: ", dict(levels)) print("Errors by source:", dict(error_sources)) print(f"Skipped {len(errors)} bad line(s):") for error in errors: print(" ", error) print() suite = unittest.defaultTestLoader.loadTestsFromModule(test_analyzer) unittest.TextTestRunner(stream=sys.stdout, verbosity=1).run(suite) """Log analyzer, end of Day 2: lines become records, streamed and validated.""" import re from collections import Counter from dataclasses import dataclass, field from datetime import datetime LEVELS = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"} # key=value, where a value is either "quoted, spaces allowed" or a single word. FIELD = re.compile(r'(?P<key>\w+)=(?P<value>"[^"]*"|\S+)') @dataclass(frozen=True) class LogRecord: timestamp: datetime level: str source: str message: str fields: dict[str, str] = field(default_factory=dict) line_number: int = 0 class ParseError(ValueError): """A line that is not a valid log record.""" def __init__(self, line_number, reason, line): super().__init__(f"line {line_number}: {reason}: {line!r}") self.line_number = line_number self.reason = reason self.line = line def parse_fields(text): """Turn 'a=1 b="two words"' into {'a': '1', 'b': 'two words'}.""" return {m["key"]: m["value"].strip('"') for m in FIELD.finditer(text)} def parse_record(line, line_number=0): """Parse one line, or raise ParseError saying what is wrong with it.""" parts = line.split(maxsplit=3) if len(parts) < 4: raise ParseError(line_number, "too few parts", line) date, time, level, rest = parts try: timestamp = datetime.fromisoformat(f"{date} {time}") except ValueError: raise ParseError(line_number, "bad timestamp", line) from None if level not in LEVELS: raise ParseError(line_number, f"unknown level {level!r}", line) source, colon, message = rest.partition(":") if not colon: raise ParseError(line_number, "missing 'source:'", line) message = message.strip() return LogRecord(timestamp, level, source, message, parse_fields(message), line_number) def read_lines(path): """Yield the lines of a file one at a time, without line endings.""" with open(path) as file: for line in file: yield line.rstrip("\n") def parse_lines(lines, errors): """Yield a record per good line; add a ParseError to the errors list per bad one.""" for number, line in enumerate(lines, start=1): if not line.strip(): continue try: yield parse_record(line, number) except ParseError as error: errors.append(error) def summarize(records): """Count levels and errors per source in a single pass.""" levels, error_sources = Counter(), Counter() for record in records: levels[record.level] += 1 if record.level == "ERROR": error_sources[record.source] += 1 return levels, error_sources import unittest from datetime import datetime from analyzer import (ParseError, parse_fields, parse_lines, parse_record, summarize) class TestParseRecord(unittest.TestCase): def test_prefix_becomes_fields(self): record = parse_record("2026-09-01 09:14:02 INFO auth: user=ada action=login") self.assertEqual(record.timestamp, datetime(2026, 9, 1, 9, 14, 2)) self.assertEqual((record.level, record.source), ("INFO", "auth")) def test_message_has_no_leading_space(self): # bug hunt 1 record = parse_record("2026-09-01 09:14:02 INFO auth: user=ada") self.assertEqual(record.message, "user=ada") class TestParseFields(unittest.TestCase): def test_quoted_value_keeps_its_spaces(self): self.assertEqual(parse_fields('msg="card declined"'), {"msg": "card declined"}) def test_two_quoted_values_stay_separate(self): # bug hunt 2 fields = parse_fields('query="python books" msg="index offline"') self.assertEqual(fields, {"query": "python books", "msg": "index offline"}) class TestStreaming(unittest.TestCase): def test_summary_works_on_a_generator(self): # bug hunt 3 lines = ["2026-09-01 09:00:00 ERROR db: x=1", "2026-09-01 09:00:01 INFO db: x=2"] levels, errors = summarize(parse_lines(iter(lines), [])) self.assertEqual(levels, {"ERROR": 1, "INFO": 1}) self.assertEqual(errors, {"db": 1}) class TestBadInput(unittest.TestCase): def test_each_kind_of_bad_line_is_reported(self): cases = { "garbage": "too few parts", "2026-13-01 09:00:00 INFO a: x=1": "bad timestamp", "2026-09-01 09:00:00 LOUD a: x=1": "unknown level 'LOUD'", "2026-09-01 09:00:00 INFO no colon here": "missing 'source:'", } for line, reason in cases.items(): with self.subTest(line=line): with self.assertRaises(ParseError) as caught: parse_record(line, 7) self.assertEqual(caught.exception.reason, reason) self.assertEqual(caught.exception.line_number, 7) def test_bad_lines_are_collected_not_fatal(self): errors = [] records = list(parse_lines(["garbage", "", "2026-09-01 09:00:00 INFO a: x=1"], errors)) self.assertEqual(len(records), 1) self.assertEqual([e.line_number for e in errors], [1]) def test_every_line_is_accounted_for(self): # bug hunt 4 lines = ["garbage", "", "2026-09-01 09:00:00 INFO a: x=1", " "] errors = [] records = list(parse_lines(lines, errors)) non_blank = [line for line in lines if line.strip()] self.assertEqual(len(records) + len(errors), len(non_blank)) 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 --- rotated at 09:16 --- 2026-09-01 09:17:45 ERROR search: query="python books" msg="index offline" 2026-09-01 9:18 INFO search: query=dune results=4 2026-09-01 09:19:30 ERROR auth: user=grace msg="token expired"

Working on your own machine? Download the Day 2 files and carry on there. You need Python 3.12 or later, and nothing else.

If your own version differs, that’s fine. What matters is that it passes the same tests. Compare it with the reference for anything you’d like to borrow.

Going further: matching on records

Optional

Nothing later in the course depends on this section.

Once lines are records, match can pick them apart by shape. A class pattern checks the type and pulls out attributes in one step, and a guard (if …) adds a condition:

match on a dataclass Python
from dataclasses import dataclass


@dataclass(frozen=True)
class LogRecord:
    level: str
    source: str
    fields: dict


def describe(record):
    match record:
        case LogRecord(level="ERROR", source="payments", fields={"order": order}):
            return f"payment failed for order {order}"
        case LogRecord(level="ERROR", source=source):
            return f"error in {source}"
        case LogRecord(fields={"retry": retry}) if int(retry) > 2:
            return f"retried {retry} times"
        case _:
            return "nothing to report"


print(describe(LogRecord("ERROR", "payments", {"order": "1043", "msg": "declined"})))
print(describe(LogRecord("ERROR", "search", {})))
print(describe(LogRecord("WARNING", "auth", {"retry": "5"})))
print(describe(LogRecord("INFO", "auth", {"user": "ada"})))

A mapping pattern like {"order": order} matches any dict that has that key, whatever else it holds. Cases are tried top to bottom, so the specific payment case has to come before the general error case. That’s the same “most specific first” rule as regex alternation.

What you can now do

  • Choose between failing fast and skipping-and-reporting, and never skip without reporting.
  • Design an exception that carries what its caller needs: a reason, a location and the bad input.
  • Test every way input can be wrong with subTest and assertRaises, checking why it was rejected, not just that it was.
  • Write a test for an invariant (“every line is accounted for”) instead of for one example.

Tomorrow the records stop disappearing when the program ends: they go into a database.

Last updated on September 18, 2026

Was this article helpful?

Your response is saved on this device.