Functions as Arguments

The Course
•
September 18, 2026
•
11 min read

Warm-up: from Day 3

From memory first.

1. Predict both lines — the second is an injection attempt.

Warm-up: predict Python
import sqlite3

conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE t (level TEXT)")
conn.executemany("INSERT INTO t VALUES (?)", [("ERROR",), ("INFO",), ("ERROR",)])

print(conn.execute("SELECT level, COUNT(*) FROM t GROUP BY level ORDER BY level").fetchall())
print(conn.execute("SELECT COUNT(*) FROM t WHERE level = ?", ("x' OR '1'='1",)).fetchone())
Check your prediction
[('ERROR', 2), ('INFO', 1)]
(0,)

Through a ? placeholder the attack is just a strange level that no row has, so the count is 0. From Queries You Can Trust.

2. Fix. Status counts saved as JSON and loaded back should equal what you saved: this should print True 5. It crashes.

Warm-up: fix Python
import json

counts = {200: 5, 404: 1}
restored = json.loads(json.dumps(counts))
print(restored == counts, restored[200])
Check your fix
import json

counts = {200: 5, 404: 1}
data = [{"status": status, "count": n} for status, n in counts.items()]
restored = {item["status"]: item["count"] for item in json.loads(json.dumps(data))}
print(restored == counts, restored[200])
True 5

JSON object keys are always strings, so 200 came back as "200". Reshaping into a list of objects keeps the numbers as numbers. From Summaries as JSON.

3. Explain. No code: count_by(conn, column, level) defends against a hostile column and a hostile level in two different ways. What are they, and why can’t it use the same one for both?

Check your explanation

level is a value, so it goes in as a ? placeholder and can never change the SQL. column is part of the SQL itself, and a placeholder can’t stand for a column name — so it’s checked against an allow-list of known columns before the SQL is built. From Queries You Can Trust.

Where Day 3 ended

Here’s where Day 3 ended: records parsed once, stored in SQLite, and summarised as JSON.

Where Day 3 ended Python
"""The Day 3 report: parse the log, store it, then ask the database.""" import sys import unittest import test_analyzer import test_store from analyzer import parse_lines, read_lines from store import connect, count_by, save_records, save_summary, summary conn = connect() # in memory; connect("logs.db") would keep it in a file errors = [] stored = save_records(conn, "messy.log", parse_lines(read_lines("messy.log"), errors)) print(f"Stored {stored} records; skipped {len(errors)} bad line(s).") print("Errors by source:", count_by(conn, "source", level="ERROR")) save_summary(summary(conn), "summary.json") with open("summary.json") as file: print(file.read()) print() loader = unittest.defaultTestLoader suite = unittest.TestSuite([loader.loadTestsFromModule(test_analyzer), loader.loadTestsFromModule(test_store)]) unittest.TextTestRunner(stream=sys.stdout, verbosity=1).run(suite) """Log analyzer, Day 3: keep parsed records in SQLite, and ask questions in SQL.""" import json import sqlite3 from datetime import datetime from analyzer import LogRecord, parse_fields SCHEMA = """ CREATE TABLE IF NOT EXISTS records ( log TEXT NOT NULL, line_number INTEGER NOT NULL, timestamp TEXT NOT NULL, -- ISO 8601, so text order is time order level TEXT NOT NULL, source TEXT NOT NULL, message TEXT NOT NULL, PRIMARY KEY (log, line_number) ) """ def connect(path=":memory:"): """Open (or create) a database with the records table in it.""" conn = sqlite3.connect(path) conn.row_factory = sqlite3.Row conn.execute(SCHEMA) return conn def save_records(conn, log, records): """Store records read from one log. Lines already stored are skipped. Returns how many new records were stored. """ rows = ((log, r.line_number, r.timestamp.isoformat(sep=" "), r.level, r.source, r.message) for r in records) with conn: # one transaction: all rows, or none cursor = conn.executemany( "INSERT INTO records VALUES (?, ?, ?, ?, ?, ?) " "ON CONFLICT (log, line_number) DO NOTHING", rows) return cursor.rowcount def record_from_row(row): """Rebuild a LogRecord. Fields are re-parsed from the message, not stored twice.""" return LogRecord( timestamp=datetime.fromisoformat(row["timestamp"]), level=row["level"], source=row["source"], message=row["message"], fields=parse_fields(row["message"]), line_number=row["line_number"], ) def load_records(conn, log): rows = conn.execute( "SELECT * FROM records WHERE log = ? ORDER BY line_number", (log,)) return [record_from_row(row) for row in rows] def like_pattern(text): """A LIKE pattern matching text anywhere, with LIKE's own wildcards made literal.""" for special in ("\\", "%", "_"): # the escape character goes first text = text.replace(special, "\\" + special) return f"%{text}%" def search(conn, text): """Records whose message contains text (ignoring ASCII case), oldest first.""" rows = conn.execute( "SELECT * FROM records WHERE message LIKE ? ESCAPE '\\' ORDER BY timestamp", (like_pattern(text),)) return [record_from_row(row) for row in rows] COUNTABLE = {"level", "source"} def count_by(conn, column, level=None): """Count records per level or per source, busiest first, optionally for one level.""" if column not in COUNTABLE: # a column name can't be a ? parameter raise ValueError(f"can't count by {column!r}; choose from {sorted(COUNTABLE)}") where, params = ("WHERE level = ?", (level,)) if level else ("", ()) rows = conn.execute( f"SELECT {column} AS key, COUNT(*) AS n FROM records {where} " f"GROUP BY {column} ORDER BY n DESC, key", params) return {row["key"]: row["n"] for row in rows} def summary(conn): """Everything the daily report needs, as plain JSON-ready values.""" total, first, last = conn.execute( "SELECT COUNT(*), MIN(timestamp), MAX(timestamp) FROM records").fetchone() hours = conn.execute( "SELECT CAST(substr(timestamp, 12, 2) AS INTEGER) AS hour, COUNT(*) AS n " "FROM records WHERE level = 'ERROR' GROUP BY hour ORDER BY hour") return { "records": total, "first": first, "last": last, "levels": count_by(conn, "level"), "errors_by_source": count_by(conn, "source", level="ERROR"), "errors_by_hour": [{"hour": row["hour"], "errors": row["n"]} for row in hours], } def save_summary(data, path): with open(path, "w") as file: json.dump(data, file, indent=2) def load_summary(path): with open(path) as file: return json.load(file) """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 sqlite3 import unittest from dataclasses import replace from analyzer import ParseError, parse_record from store import (connect, count_by, load_records, load_summary, save_records, save_summary, search, summary) LINES = [ "2026-09-01 09:14:02 INFO auth: user=ada action=login", "2026-09-01 09:15:30 ERROR payments: order=1043 msg=\"card declined\"", "2026-09-01 10:02:11 ERROR db: msg=\"can't reach replica\"", ] def records(): return [parse_record(line, number) for number, line in enumerate(LINES, start=1)] class TestStore(unittest.TestCase): def setUp(self): self.conn = connect() # a new, empty database for every test self.addCleanup(self.conn.close) def test_records_come_back_equal(self): # bug hunt 1 save_records(self.conn, "a.log", records()) self.assertEqual(load_records(self.conn, "a.log"), records()) def test_search_text_with_a_quote(self): # bug hunt 2 save_records(self.conn, "a.log", records()) found = search(self.conn, "can't") self.assertEqual([r.source for r in found], ["db"]) def test_search_is_not_sql(self): save_records(self.conn, "a.log", records()) self.assertEqual(search(self.conn, "x' OR '1'='1"), []) def test_saving_the_same_log_twice_stores_it_once(self): # bug hunt 3 self.assertEqual(save_records(self.conn, "a.log", records()), 3) self.assertEqual(save_records(self.conn, "a.log", records()), 0) self.assertEqual(count_by(self.conn, "level"), {"ERROR": 2, "INFO": 1}) def test_search_results_are_oldest_first(self): # Day 3 exercise save_records(self.conn, "a.log", reversed(records())) # stored newest first self.assertEqual([r.source for r in search(self.conn, "msg=")], ["payments", "db"]) def test_search_treats_wildcards_as_text(self): save_records(self.conn, "a.log", records()) self.assertEqual(search(self.conn, "%"), []) self.assertEqual(search(self.conn, "_"), []) def test_a_failed_save_stores_nothing(self): # Day 3 exercise good = records() bad = replace(good[1], source=None) # NOT NULL: the database refuses it with self.assertRaises(sqlite3.IntegrityError): save_records(self.conn, "a.log", [good[0], bad, good[2]]) self.assertEqual(count_by(self.conn, "level"), {}) def test_a_stream_that_fails_part_way_stores_nothing(self): def stream(): yield records()[0] raise ParseError(2, "too few parts", "garbage") # fail-fast parsing, mid-save with self.assertRaises(ParseError): save_records(self.conn, "a.log", stream()) self.assertEqual(count_by(self.conn, "level"), {}) def test_count_by_one_level(self): save_records(self.conn, "a.log", records()) self.assertEqual(count_by(self.conn, "source", level="ERROR"), {"db": 1, "payments": 1}) def test_count_by_refuses_other_columns(self): with self.assertRaises(ValueError): count_by(self.conn, "message; DROP TABLE records") class TestSummary(unittest.TestCase): def test_summary_survives_a_round_trip_through_json(self): # bug hunt 4 conn = connect() self.addCleanup(conn.close) save_records(conn, "a.log", records()) data = summary(conn) save_summary(data, "test-summary.json") self.assertEqual(load_summary("test-summary.json"), data) self.assertEqual(data["errors_by_hour"], [{"hour": 9, "errors": 1}, {"hour": 10, "errors": 1}]) 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"

People have started asking the analyzer questions nobody planned for. Errors per hour. Warnings per source. Payment problems after 10:00. Each new question so far has meant a new function, and they’re starting to look alike.

Three loops, one idea

Here are three counting functions from earlier in the week, side by side:

def count_levels(records):
    counts = Counter()
    for r in records:
        counts[r.level] += 1
    return counts


def errors_by_source(records):
    counts = Counter()
    for r in records:
        if r.level == "ERROR":
            counts[r.source] += 1
    return counts


def errors_by_hour(records):
    counts = Counter()
    for r in records:
        if r.level == "ERROR":
            counts[r.timestamp.hour] += 1
    return counts

Only two things differ: what to count by (r.level, r.source, r.timestamp.hour) and which records to include (all of them, or just the errors). Everything else is the same loop, copied.

When the part that varies is behaviour rather than a value, make it a function, and pass it in:

One count, configured by functions Python
from collections import Counter
from dataclasses import dataclass


@dataclass(frozen=True)
class LogRecord:
    hour: int
    level: str
    source: str


RECORDS = [
    LogRecord(9, "INFO", "auth"),
    LogRecord(9, "ERROR", "payments"),
    LogRecord(9, "ERROR", "search"),
    LogRecord(10, "ERROR", "payments"),
    LogRecord(10, "WARNING", "payments"),
]


def count(records, key, keep=None):
    """Count records by key(record), counting only those where keep(record) is true."""
    return Counter(key(r) for r in records if keep is None or keep(r))


def is_error(record):
    return record.level == "ERROR"


print(count(RECORDS, key=lambda r: r.level))
print(count(RECORDS, key=lambda r: r.source, keep=is_error))
print(count(RECORDS, key=lambda r: r.hour, keep=is_error))

Three functions became one, and the fourth question, and the fortieth, cost one line each instead of a new function.

This is the same idea as sorted(records, key=...), which you met in the beginner course. sorted knows how to sort. You tell it what to sort by. count knows how to count, and you tell it what to count by and what to count.

lambda or def?

key=lambda r: r.source and keep=is_error are both functions. The only difference is that one has a name.

  • Use lambda for a short expression that means nothing outside this one call, like lambda r: r.source.
  • Use def as soon as it has a meaning of its own, is used twice, needs a docstring or a test, or wouldn’t fit comfortably on one line. is_error reads better at the call site than lambda r: r.level == "ERROR", and a typo in it gets fixed in one place.

Note keep=is_error, not keep=is_error(). You’re handing over the function itself, for count to call later, once per record. With brackets you’d call it now, with no record, and get a TypeError.

Letting the caller decide: on_error

On Day 2, parse_lines took a list and appended each bad line’s ParseError to it. The bug hunt showed how easy that was to misuse: pass [], and the errors are collected and thrown away. The lesson promised a better design, and here it is. Instead of a list, parse_lines takes a function, on_error, and calls it with each error:

def parse_lines(lines, on_error=None):
    """Yield a record per good line.

    A bad line's ParseError is passed to on_error. With no on_error it is raised,
    so by default the first bad line stops everything: nothing is dropped silently.
    """
    for number, line in enumerate(lines, start=1):
        if not line.strip():
            continue
        try:
            record = parse_record(line, number)
        except ParseError as error:
            if on_error is None:
                raise
            on_error(error)
        else:
            yield record

A function you pass in for someone else to call back later is a callback. This one lets a single parse_lines support every strategy from Day 2, without knowing which one it’s serving:

The caller passesWhat happens to a bad line
nothingParseError is raised: fail fast
errors.appendkept in a list: skip and report
printprinted, then skipped
a function of your ownwhatever you decide, e.g. count them by reason

errors.append is a bound method: a function that already knows which list it appends to. Passing it is how you say “put the errors in this list”.

The most important line is the default. Forgetting on_error doesn’t drop errors. It stops at the first one. Day 2’s silent drop needed nothing more than a missing name, and now it needs a deliberate on_error=lambda error: None, which no one writes by accident.

try / except / else

The else block runs only if the try raised nothing. Putting yield record there, rather than inside the try, means the except catches errors from parse_record and nothing else.

Bug hunt: the report that saw no errors

The report below uses the new on_error. It says there were no bad lines, but there’s one in the input, and the levels add up to only four of the five lines:

Skipped 0 bad lines? Python
from collections import Counter


def parse_lines(lines, on_error=None):
    for number, line in enumerate(lines, start=1):
        parts = line.split()
        if len(parts) < 4:
            if on_error is None:
                raise ValueError(f"line {number}: too few parts")
            on_error(f"line {number}: too few parts")
        else:
            yield parts[2]          # just the level, to keep the example short


LINES = [
    "2026-09-01 09:14:02 INFO    auth: user=ada",
    "2026-09-01 09:15:30 ERROR   payments: order=1043",
    "garbage",
    "2026-09-01 09:17:45 ERROR   search: query=dune",
    "2026-09-01 09:18:02 INFO    search: query=dune",
]

errors = []
levels = parse_lines(LINES, on_error=errors.append)
print(f"Skipped {len(errors)} bad line(s)")
print("Levels:", dict(Counter(levels)))

Reproduce. Five lines, one of them garbage. The report says it skipped 0.

Inspect. Print the state that’s wrong, at the end rather than where it’s used. Add print(errors) as the very last line. It prints ['line 3: too few parts']. So the error was collected. It just wasn’t there yet when the report printed its count.

Before you open this, explain in one sentence why errors was still empty when the count was printed. Then fix the report and compare.

Show the diagnosis and the fix

Narrow. parse_lines is a generator. Calling it runs none of its body. It starts running only when Counter(levels) asks for the first value. So at the moment len(errors) is evaluated, not a single line has been parsed, and the callback hasn’t been called. A callback runs when the code that calls it runs, and with a generator, that’s later than it looks.

Pin it. A test that states when errors become known:

def test_errors_are_known_once_the_records_are_read(self):
    errors = []
    records = parse_lines(["garbage", GOOD], on_error=errors.append)
    self.assertEqual(errors, [])            # a generator: nothing has run yet
    list(records)
    self.assertEqual(len(errors), 1)

Fix. Report the errors after the records have been consumed. Swap the two print lines and run again.

Callbacks and laziness

Whenever a callback is passed to something lazy, like a generator, an iterator or a map, the callback runs when the values are pulled, not when the call is made. Read the results of a callback only after you’ve finished reading the stream.

Your turn

Exercise

count and group

Write two functions that take the varying part as an argument:

  • count(records, key, keep=None), as in this lesson.
  • group(records, key) returns a dict mapping each key(record) to the list of records with that key, in their original order.

The last test uses both to answer a real question. Read it to see how functions as arguments read at the call site.

import sys
import unittest
from collections import Counter
from dataclasses import dataclass


@dataclass(frozen=True)
class LogRecord:
    hour: int
    level: str
    source: str


def count(records, key, keep=None):
    raise NotImplementedError("your code here")


def group(records, key):
    raise NotImplementedError("your code here")


RECORDS = [
    LogRecord(9, "INFO", "auth"),
    LogRecord(9, "ERROR", "payments"),
    LogRecord(10, "ERROR", "search"),
    LogRecord(10, "ERROR", "payments"),
]


class TestFunctionsAsArguments(unittest.TestCase):
    def test_count_everything(self):
        self.assertEqual(count(RECORDS, key=lambda r: r.level), {"INFO": 1, "ERROR": 3})

    def test_count_some(self):
        self.assertEqual(count(RECORDS, key=lambda r: r.hour, keep=lambda r: r.source == "payments"),
                         {9: 1, 10: 1})

    def test_group_keeps_the_original_order(self):
        groups = group(RECORDS, key=lambda r: r.hour)
        self.assertEqual(list(groups), [9, 10])
        self.assertEqual(groups[10], [RECORDS[2], RECORDS[3]])

    def test_the_busiest_error_hour_and_who_was_in_it(self):
        def is_error(record):
            return record.level == "ERROR"

        hour, _ = count(RECORDS, key=lambda r: r.hour, keep=is_error).most_common(1)[0]
        sources = [r.source for r in group(RECORDS, key=lambda r: r.hour)[hour] if is_error(r)]
        self.assertEqual((hour, sources), (10, ["search", "payments"]))


suite = unittest.defaultTestLoader.loadTestsFromTestCase(TestFunctionsAsArguments)
unittest.TextTestRunner(stream=sys.stdout, verbosity=2).run(suite)
Hint 1: the idea

The part of each old loop that varied — what to count by, which records to keep — becomes a function the caller passes in. Inside, call key(record) wherever the old loops read record.level or record.hour. For keep, None means “no filter”: count everything.

Hint 2: a first step

Write count first: the loop from Three loops, one idea with key(record) in place of the field, returning a Counter. When both count tests pass, group is the same loop — appending each record to a list for its key instead of adding 1.

Show one solution
def count(records, key, keep=None):
    """Count records by key(record), counting only those where keep(record) is true."""
    return Counter(key(r) for r in records if keep is None or keep(r))


def group(records, key):
    """{key(record): [records with that key]}, each list in the original order."""
    groups = {}
    for record in records:
        groups.setdefault(key(record), []).append(record)
    return groups

count returns a Counter, not a plain dict, and the last test relies on that: most_common(1) is a Counter method. Returning the most useful type costs nothing, because a Counter still compares equal to an ordinary dict.

setdefault(k, []) returns the list for k, creating an empty one first if there isn’t one. collections.defaultdict(list) does the same job, but it hands back a defaultdict, and a caller who looks up a missing key then silently gets a new empty list instead of a KeyError.

What you can now do

  • Spot the part of near-duplicate code that is behaviour, and pass it in as a function.
  • Choose between lambda and def, and pass a function without calling it.
  • Design a callback with a safe default, like on_error failing fast unless told otherwise.
  • Remember that a callback passed to a generator runs only when the generator is read.

Next: functions that make functions, so keep= arguments read like English.

Last updated on September 18, 2026

Was this article helpful?

Your response is saved on this device.