Warm-up: from Day 2
From memory first: the effort of recalling is what makes it stick.
1. Predict both lines.
line = '2026-09-01 09:15:30 ERROR payments: order=1043 msg="card declined"'
date, time, level, rest = line.split(maxsplit=3)
source, _, message = rest.partition(":")
print(level, source)
print(message.strip().split()[0])Check your prediction
ERROR payments
order=1043split(maxsplit=3) stops after three cuts, so rest keeps the whole tail;
partition(":") cuts at the first colon only. From From Lines to Records.
2. Fix. This should report the count and then list the errors. The list comes out empty.
def errors(lines):
for line in lines:
if "ERROR" in line:
yield line
found = errors(["ERROR a", "INFO b", "ERROR c"])
print(len(list(found)), "errors")
print(list(found))Check your fix
def errors(lines):
for line in lines:
if "ERROR" in line:
yield line
found = list(errors(["ERROR a", "INFO b", "ERROR c"]))
print(len(found), "errors")
print(found)2 errors
['ERROR a', 'ERROR c']A generator can be read once; the first list(found) used it up. Day 2’s bug
hunt in Streaming a Big Log was this, hiding inside a report.
3. Explain. No code: why does Day 2’s parser collect bad lines in an
errors list instead of raising on the first one?
Check your explanation
So that one bad line doesn’t stop the whole report: every good line still gets counted, and every bad line is reported at the end — with its line number and the reason — instead of silently vanishing or crashing the run. From Rejecting Bad Input.
Where Day 2 ended
Here’s where Day 2 ended: a streaming parser that turns lines into records and reports every line it rejects.
Every question the analyzer answers today means reading and parsing the whole log again, and when the program ends the records are gone. That’s fine for one report. It’s not fine when someone asks “how many payment errors were there last Tuesday between 9 and 10?” and the log is a gigabyte. Parse once, keep the records, and ask as many questions as you like. That’s what a database is for.
SQLite: a database in a file
SQLite is a complete SQL database that lives in a single file, with no
server to install or run. Python ships with it as the sqlite3 module. It’s in
every phone, every browser and most desktop apps, and it’s what’s running in
this page.
import sqlite3
conn = sqlite3.connect(":memory:") # a database in memory; use a file name to keep it
conn.execute("""
CREATE TABLE records (
timestamp TEXT NOT NULL,
level TEXT NOT NULL,
source TEXT NOT NULL,
message TEXT NOT NULL
)
""")
rows = [
("2026-09-01 09:14:02", "INFO", "auth", "user=ada action=login"),
("2026-09-01 09:15:30", "ERROR", "payments", "order=1043"),
("2026-09-01 09:17:45", "ERROR", "search", "query=dune"),
("2026-09-01 10:02:11", "ERROR", "payments", "order=1050"),
]
with conn: # a transaction: committed if the block succeeds
conn.executemany("INSERT INTO records VALUES (?, ?, ?, ?)", rows)
print(conn.execute("SELECT COUNT(*) FROM records").fetchone())
query = """
SELECT source, COUNT(*) FROM records
WHERE level = 'ERROR'
GROUP BY source
ORDER BY COUNT(*) DESC
"""
for source, count in conn.execute(query):
print(f"{source:10} {count}")The whole API you need today is small:
| Call | Does |
|---|---|
sqlite3.connect(path) | Opens a database file, creating it if needed. ":memory:" gives a fresh, empty one that disappears when closed. |
conn.execute(sql, params) | Runs one statement. For a SELECT, iterate over the result, or call .fetchone() or .fetchall(). |
conn.executemany(sql, rows) | Runs one statement once per item in rows. Any iterable works, including a generator. |
with conn: | Wraps the block in a transaction: commit if it finishes, roll back if it raises. |
The ? in the INSERT is a placeholder. sqlite3 fills it in with the
matching value, safely. The next lesson is about why you should never build SQL
any other way.
Rows with names
By default each row is a tuple, so you have to remember that column 2 is the
source. Set row_factory to sqlite3.Row and rows can also be read by column
name:
import sqlite3
conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row
conn.execute("CREATE TABLE records (level TEXT, source TEXT)")
conn.execute("INSERT INTO records VALUES (?, ?)", ("ERROR", "payments"))
row = conn.execute("SELECT * FROM records").fetchone()
print(row["source"], row["level"]) # by name
print(row[0], tuple(row)) # still works like a tuple
print(row.keys())SQL in five clauses
You’ll use five clauses all week. A query reads in this order, even though the database works out the answer differently:
SELECT source, COUNT(*) AS n -- which columns, or calculations over them
FROM records -- which table
WHERE level = 'ERROR' -- keep only these rows
GROUP BY source -- one output row per distinct source
ORDER BY n DESC -- sort the output
COUNT(*), MIN(...), MAX(...) and SUM(...) are aggregates. With
GROUP BY they’re calculated once per group, and without it, once for the
whole table. That one query replaces the Counter loop from Day 2, and it runs
inside the database without building a single Python object per row.
Bug hunt: the record that came back different
SQLite has only five kinds of value: NULL, INTEGER, REAL, TEXT and
BLOB. There’s no date type. Here’s a first attempt at saving LogRecords and
loading them back, with a test checking the round trip:
import sqlite3
import sys
import unittest
from dataclasses import dataclass
from datetime import datetime
@dataclass(frozen=True)
class LogRecord:
timestamp: datetime
level: str
source: str
def save(conn, records):
with conn:
conn.executemany("INSERT INTO records VALUES (?, ?, ?)",
[(r.timestamp, r.level, r.source) for r in records])
def load(conn):
return [LogRecord(*row) for row in conn.execute("SELECT * FROM records")]
class TestRoundTrip(unittest.TestCase):
def test_records_come_back_equal(self):
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE records (timestamp TEXT, level TEXT, source TEXT)")
original = [LogRecord(datetime(2026, 9, 1, 9, 14, 2), "INFO", "auth")]
save(conn, original)
self.assertEqual(load(conn), original)
suite = unittest.defaultTestLoader.loadTestsFromTestCase(TestRoundTrip)
unittest.TextTestRunner(stream=sys.stdout, verbosity=2).run(suite)Reproduce. One record, one test, and it fails.
Inspect. Read the two records in the failure message side by side. One has
timestamp='2026-09-01 09:14:02', a string. The other has
timestamp=datetime.datetime(2026, 9, 1, 9, 14, 2). Same moment, but one of them
is text, and text never equals a datetime. Look at the rest of the output too. There’s a DeprecationWarning: “The
default datetime adapter is deprecated”.
Before you open this: why did the timestamp come back as a string? The warning is a clue. Then try to make the round trip pass. Open the reveal afterwards even if you did: it explains how sqlite3 converted the value behind your back, which matters for everything else you store.
The diagnosis and the fix (read it either way)
Narrow. The datetime never reached SQLite as a date, because SQLite has no
dates. sqlite3 quietly converted it to text using a built-in “adapter”, which
is deprecated since Python 3.12 precisely because it’s so easy to miss. On the
way out, nothing converted it back, so the record is rebuilt around a string.
Nothing failed until a test compared the two.
Pin it. The test you just ran is the regression test: save, load, compare the whole record.
Fix. Do the conversion yourself, explicitly, in both directions:
def save(conn, records):
with conn:
conn.executemany("INSERT INTO records VALUES (?, ?, ?)",
[(r.timestamp.isoformat(sep=" "), r.level, r.source) for r in records])
def load(conn):
return [LogRecord(datetime.fromisoformat(ts), level, source)
for ts, level, source in conn.execute("SELECT * FROM records")]Make both changes in the playground and run it again.
Convert at the edge, in both directions
It’s the same rule as parsing on Day 2. Python values become storage values in
exactly one place on the way in, and turn back in exactly one place on the way
out. ISO 8601 text is a good storage format for times, because text order is
time order, so ORDER BY timestamp and MIN(timestamp) just work, as long as
every time is written the same way. That means the same layout, and no mix of
UTC offsets: 09:00:00+02:00 would sort after 08:30:00+00:00, although it
happened earlier. The analyzer’s logs use one local format, and that’s the
assumption this course makes. A tool that meets several time zones would
convert every time to UTC before storing it.
Your turn
Ask the log three questions
The playground below loads eight records. Write three queries, each one
SELECT statement, and print their results:
- How many records are there per level?
- Which sources logged an error after 10:00, and how many each?
- What were the first and last timestamps in the log?
The answers you should get: ERROR 5, INFO 2, WARNING 1; after 10:00,
payments 2 and db 1; and the log runs from 2026-09-01 09:14:02 to
2026-09-01 11:00:00.
import sqlite3
conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row
conn.execute("CREATE TABLE records (timestamp TEXT, level TEXT, source TEXT, message TEXT)")
with conn:
conn.executemany("INSERT INTO records VALUES (?, ?, ?, ?)", [
("2026-09-01 09:14:02", "INFO", "auth", "user=ada action=login"),
("2026-09-01 09:14:07", "WARNING", "payments", "order=1042 retry=1"),
("2026-09-01 09:15:30", "ERROR", "payments", "order=1043"),
("2026-09-01 09:17:45", "ERROR", "search", "query=dune"),
("2026-09-01 10:02:11", "ERROR", "payments", "order=1050"),
("2026-09-01 10:05:00", "INFO", "auth", "user=alan action=login"),
("2026-09-01 10:31:40", "ERROR", "db", "replica lag"),
("2026-09-01 11:00:00", "ERROR", "payments", "order=1061"),
])
# 1. records per level
# 2. errors after 10:00, per source
# 3. first and last timestampHint 1: the idea
Each question is one SELECT. “Per level” and “per source” mean GROUP BY
with COUNT(*). “After 10:00” is a WHERE. “First and last” are MIN and
MAX over the timestamp. SQL in five clauses above has one line on each.
Hint 2: a first step
Start with question 1: SELECT level, COUNT(*) AS n FROM records GROUP BY level,
looping over the rows to print them. You should see ERROR 5, INFO 2 and
WARNING 1. For question 2, the timestamps are text, so compare against a
timestamp written in exactly the same format.
Show one solution
for row in conn.execute(
"SELECT level, COUNT(*) AS n FROM records GROUP BY level ORDER BY n DESC"):
print(row["level"], row["n"])
for row in conn.execute("""
SELECT source, COUNT(*) AS n FROM records
WHERE level = 'ERROR' AND timestamp >= '2026-09-01 10:00:00'
GROUP BY source ORDER BY n DESC"""):
print(row["source"], row["n"])
first, last = conn.execute("SELECT MIN(timestamp), MAX(timestamp) FROM records").fetchone()
print(first, "to", last)The second query compares timestamps as text, and gets the right answer
only because they’re ISO 8601: year first, then month, day, hour, all padded
with zeros. "2026-09-01 9:05" would sort after "2026-09-01 10:00", because
"9" comes after "1". That’s one more reason to store times in exactly one
format, converted at the edge.
What you can now do
- Create a SQLite table, insert rows with
executemany, and query them. - Use
with conn:to commit a group of changes together. - Read rows by column name with
sqlite3.Row. - Write
SELECT … WHERE … GROUP BY … ORDER BYwith aggregates. - Convert values like
datetimeexplicitly on the way in and out of storage.
Next: the ? placeholders, and what goes wrong without them.