Records into SQLite

The Course
•
September 18, 2026
•
10 min read

Warm-up: from Day 2

From memory first: the effort of recalling is what makes it stick.

1. Predict both lines.

Warm-up: predict Python
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=1043

split(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.

Warm-up: fix Python
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.

Where Day 2 ended 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"

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.

Create, insert, query Python
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:

CallDoes
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:

sqlite3.Row Python
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:

Save and load Python
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

Exercise

Ask the log three questions

The playground below loads eight records. Write three queries, each one SELECT statement, and print their results:

  1. How many records are there per level?
  2. Which sources logged an error after 10:00, and how many each?
  3. 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 timestamp
Hint 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 BY with aggregates.
  • Convert values like datetime explicitly on the way in and out of storage.

Next: the ? placeholders, and what goes wrong without them.

Last updated on September 18, 2026

Was this article helpful?

Your response is saved on this device.