The Edge Cases You've Been Avoiding

The Course
•
September 19, 2026
•
11 min read

Warm-up: from Day 6

The last warm-up of the course, from memory first.

1. Predict which keys survive.

Warm-up: predict Python
from collections import OrderedDict

cache = OrderedDict()
for key in "abc":
    cache[key] = key.upper()

cache.move_to_end("a")
cache["d"] = "D"
while len(cache) > 3:
    cache.popitem(last=False)

print(list(cache))
Check your prediction
['c', 'a', 'd']

move_to_end made a the most recently used, so b was at the front when the cache grew past three. From Memory: Finding What Never Goes Away.

2. Fix. Four lookups for three distinct ids should cost one query: this should print 3 1.

Warm-up: fix Python
queries = 0

def fetch(ids):
    global queries
    queries += 1
    return {i: f"row {i}" for i in ids}

rows = {}
for i in [3, 1, 3, 2]:
    rows.update(fetch([i]))

print(len(rows), queries)
Check your fix
queries = 0

def fetch(ids):
    global queries
    queries += 1
    return {i: f"row {i}" for i in ids}

rows = fetch(list(dict.fromkeys([3, 1, 3, 2])))

print(len(rows), queries)
3 1

One query per item is the N+1 pattern. De-duplicate, then ask for everything at once. From The Slow Path: One Query at a Time.

3. Explain. No code: why do this library’s tests count queries instead of timing things?

Check your explanation

A count is deterministic — the same on every machine, under any load — and for a data library it’s the thing that matters. A timing assertion passes on a quiet laptop, fails on a busy CI machine, and teaches everyone to ignore red builds. From Measuring Before Optimising.

Today

Six days in, the library can create, read and update. It cannot delete, and it has no answer to “someone else changed this row”. Both gaps are small to fill and interesting to design, because both interact with the identity map.

delete: three decisions in eight lines

    def delete(self, connection: Connection) -> Self:
        """Remove this row. The object stays usable, and saving it inserts anew."""
        if self.id is None:
            raise ORMError(f"this {type(self).__name__} has never been saved")
        with transaction(connection):
            connection.execute(f"delete from {self.table} where id = ?", (self.id,))
        type(self)._cache_for(connection).pop(self.id, None)   # or get() would still find it
        self.id = None
        return self

Deleting something unsaved is an error, not a no-op. A caller who deletes an object that was never saved has a bug — a lost save(), a wrong variable — and silence would hide it. The alternative view (“delete is idempotent, doing nothing is fine”) is defensible for a delete_by_id(1) API; for a method on an object that knows whether it exists, raising is better.

The cache must forget it. Without the pop, Post.get(connection, 1) returns the deleted object from memory, cheerfully, with no query. That’s this day’s bug hunt.

self.id = None makes the object reusable. After a delete it looks exactly like a never-saved object, so save() inserts it again with a new id. That’s a choice: the alternative is to mark it dead and raise on any further use. This library picks reusable because it matches how the rest of the API treats id — None means “not in the database” everywhere else, and consistency is worth more here than protecting against an unlikely mistake.

Deleting inside a transaction

delete opens a transaction even for one statement. It costs a savepoint when nested, and it means delete composes with save_all the way callers expect. Uniformity is worth a little overhead in a library: an API where some operations are atomic is harder to reason about than one where all of them are.

refresh: the answer to a stale cache

The identity map has always had an obvious hole: it hands back an object that was loaded at some point in the past, and rows change. Another process, a migration, raw SQL in a shell — the library can’t know.

    def refresh(self, connection: Connection) -> Self:
        if self.id is None:
            raise ORMError(f"this {type(self).__name__} has never been saved")
        row = connection.execute(
            f"select * from {self.table} where id = ?", (self.id,)
        ).fetchone()
        if row is None:
            raise NotFound(f"no {type(self).__name__} with id {self.id}")
        fields = type(self).fields
        # Validate the whole incoming row before changing anything.
        validated = {name: fields[name].check(row[name]) for name in fields}
        for name, value in validated.items():
            setattr(self, name, value)
        self._remember(connection)
        return self

Two decisions carry this method.

Go through the fields. The values are validated exactly as if a caller had assigned them — Day 3’s lesson, applied to the one method whose whole job is to write attributes from database rows.

Validate the whole row before assigning any of it. If you setattr field by field, a value the fields reject partway through leaves the object half-updated: the new title in place, the old views still there, and an exception on its way up. That breaks the rule Day 4.3 pins down — an operation that raises should leave its subject untouched. Building the validated dict first means the bad value raises before a single attribute has changed. There’s a test for exactly this, and it’s the sort of thing the first draft of this method got wrong.

A row changed behind the library's back Python
import sqlite3

connection = sqlite3.connect(":memory:", isolation_level=None)
connection.row_factory = sqlite3.Row
connection.execute("create table post (id integer primary key, views integer)")
connection.execute("insert into post (id, views) values (1, 1)")


class Post:
    cache = {}

    def __init__(self, id, views):
        self.id, self.views = id, views

    @classmethod
    def get(cls, id):
        if id not in cls.cache:
            row = connection.execute("select * from post where id = ?", (id,)).fetchone()
            cls.cache[id] = cls(row["id"], row["views"])
        return cls.cache[id]

    def refresh(self):
        row = connection.execute("select * from post where id = ?", (self.id,)).fetchone()
        self.views = row["views"]
        return self


post = Post.get(1)
print("loaded:            ", post.views)

connection.execute("update post set views = 99 where id = 1")     # somebody else
print("after their update:", Post.get(1).views, "(from the cache)")
print("after refresh:     ", post.refresh().views)
print("and the cached one:", Post.get(1).views, "(same object)")

The last line is the identity map paying off: because get returns the same object, refreshing it updates every reference the program holds. A cache that returned copies would leave stale ones scattered around.

The identity map needs a scope

Here is a bug that stayed hidden until someone used two databases. The cache was keyed by row id alone, so get served whatever it had cached for id 1 — from whichever database loaded it first:

a, b = connect(), connect()
Post(title="only in A", views=1).save(a)
Post.get(b, 1)          # returned A's object, though b has no such row

That is the Day 1 cache-key lesson wearing new clothes. Day 1’s bug was a cache keyed by id when it should have included the class; this one is keyed by id when it should include the database. A row’s identity is only meaningful inside the connection it came from, so the cache has to be scoped that way:

    @classmethod
    def _cache_for(cls, connection: Connection) -> "OrderedDict[int, Any]":
        return cls._instances.setdefault(connection, OrderedDict())

_instances becomes {connection: {id: object}}, and get, get_many, _remember and delete all work through _cache_for(connection). Cross- database collisions vanish, and the guarantee tightens honestly: one object per row per connection. It does introduce a lifetime question — a connection’s entry lives until you drop it — so the library adds a per-model Post.forget(connection) and a Model.forget_all(connection) that clears every model. (Model.forget on the base has no cache of its own; call it on a concrete model.)

Writing down what you won’t fix

Not every hole is closed. save and save_all now undo their own id when a write rolls back, but an object saved inside an outer transaction that the caller then rolls back keeps its id and its cache entry — because the cache and the object’s state don’t join the transaction. Fixing that properly means deferring cache and id changes to the outermost commit, which is a real piece of design, not a patch.

The honest move is to scope the claim precisely and give a workaround that actually works. Model.forget_all(connection) alone does not — it clears the cache, but the object you still hold keeps its phantom id, so re-saving it issues a zero-row UPDATE and re-caches something that isn’t there. The supported recovery is: after a rollback you drive yourself, forget the caches and discard the affected objects, reloading existing rows and rebuilding new ones without the rolled-back id.

with contextlib.suppress(RuntimeError):
    with transaction(connection):
        post.save(connection)
        raise RuntimeError("changed my mind")

Model.forget_all(connection)          # 1. invalidate the caches
post = Post(title="outer", views=1)   # 2. rebuild; don't reuse the stale object
post.save(connection)

The same applies to updates and deletes inside a rolled-back outer unit, not only inserts. There’s a passing test for this recovery and an expectedFailure for the underlying limitation:

class KnownLimitations(Base):
    @unittest.expectedFailure
    def test_a_save_inside_an_outer_rollback_stays_cached(self) -> None:
        """Known limitation: object state is not tied to an outer transaction.

        `save`/`save_all` undo their own id on failure, but an object saved
        inside an outer transaction the caller then rolls back keeps its id and
        its cache entry. The supported recovery (forget + rebuild) has its own
        passing test; a full fix defers cache and id changes to the outermost
        commit.
        """

@unittest.expectedFailure inverts the result: the suite is green while the bug exists, and it goes red the day somebody fixes it — which is exactly when you want to be told, so the note can come out of the README and the test can lose its decorator.

Expected failures are a commitment, not a shrug

Two rules keep them honest. The docstring must say what the limitation is and what fixing it would take. And there should be very few — an expectedFailure is a documented defect, and a suite with a dozen of them is a suite nobody trusts.

Exercise

Decide what delete owes its caller

Here is a Session with an object cache and a delete that is wrong in three different ways. The tests say what callers need; make them pass.

import unittest


class NotFound(LookupError):
    pass


class Session:
    """A tiny store with an identity map."""

    def __init__(self):
        self.rows = {1: "first", 2: "second", 3: "third"}
        self.cache = {}
        self.queries = 0

    def get(self, id):
        if id in self.cache:
            return self.cache[id]
        self.queries += 1
        if id not in self.rows:
            raise NotFound(f"no row {id}")
        self.cache[id] = {"id": id, "value": self.rows[id]}
        return self.cache[id]

    def delete(self, id):
        """TODO: fix. It must
           - raise NotFound for a row that isn't there
           - remove the row
           - leave nothing behind that a later get() could return
           - report how many rows it removed (1), so callers can act on it
        """
        del self.rows[id]


class Deleting(unittest.TestCase):
    def setUp(self):
        self.session = Session()

    def test_the_row_is_gone(self):
        self.session.delete(1)
        with self.assertRaises(NotFound):
            self.session.get(1)

    def test_a_cached_row_is_gone_too(self):
        self.session.get(1)                      # now cached
        self.session.delete(1)
        with self.assertRaises(NotFound):
            self.session.get(1)

    def test_deleting_something_missing_raises(self):
        with self.assertRaises(NotFound):
            self.session.delete(99)

    def test_it_reports_what_it_did(self):
        self.assertEqual(self.session.delete(1), 1)

    def test_other_rows_are_untouched(self):
        self.session.get(2)
        self.session.delete(1)
        self.assertEqual(self.session.get(2)["value"], "second")


unittest.TextTestRunner(verbosity=2).run(
    unittest.defaultTestLoader.loadTestsFromTestCase(Deleting)
)
Hint 1: the idea

Four promises, from the docstring: raise NotFound for a row that isn’t there, remove the row, leave nothing a later get could return, and report how many rows went. One of them hides: the first test passes without it, because only a row that was read before being deleted can come back.

Hint 2: a first step

Check that the row exists first, raising NotFound; then delete it and return 1. Run, then look at the one test still failing and ask: after get(1), where else does row 1 live?

Show one solution
    def delete(self, id):
        if id not in self.rows:
            raise NotFound(f"no row {id}")
        del self.rows[id]
        self.cache.pop(id, None)
        return 1

Three lines, three bugs. Checking first turns a KeyError — which leaks the fact that rows live in a dict — into the library’s own NotFound. Popping the cache is the one that would survive review and fail in production, because the first test passes without it: only a row that was read before being deleted comes back from the dead.

self.cache.pop(id, None) rather than del self.cache[id]: the row may never have been cached, and cleanup that can itself raise is the pattern to avoid (Day 4).

Returning the count is the smallest of the four and the most API-ish: a caller writing if session.delete(id): can tell a real delete from a no-op, and returning 1 now leaves room for a delete_where that returns more later.

What you learned

  • Every method that touches a row has to decide what it owes the identity map; delete must evict, refresh must write through the fields.
  • Validate a whole incoming row before assigning any of it, so a rejected value leaves the object unchanged.
  • A cache key must identify the row in its scope: by class (Day 1) and by connection (today), or it hands back the wrong object.
  • Choose deliberately between raising and no-op for “that isn’t there”, and make the choice consistent across the API.
  • @unittest.expectedFailure turns a known limitation into executable documentation that tells you when it’s fixed — kept few, and scoped honestly.
Last updated on September 19, 2026

Was this article helpful?

Your response is saved on this device.