Everything this week has been a decision with a cost: an identity map that can go stale, a cache that evicts, validation on every load, a module-level driver registration. Each was reasonable. None is obvious from reading the code.
Documentation is where those decisions survive, and a library’s docs have three separate jobs.
What the README owes a reader
Someone lands on your project with one question: can this do what I need, and what will it cost me? Answer that in the first screen.
The library’s README leads with what it deliberately isn’t:
A deliberately small ORM: typed fields, a model registry, get/save/delete and
transactions, over any DB-API connection. No joins, no relationships, no
migrations, no async — on purpose.
Then two sections that matter more than any tutorial:
What it guarantees. The promises a caller may build on — validation on the way in and out, all-or-nothing writes, one object per row while cached, its own exception hierarchy, no driver types in the API.
What it does not do, as a table, with a workaround for each:
| Limitation | Why | Workaround |
|---|---|---|
get_many never chunks | One query, however many ids | Chunk at the call site; the limit is the driver’s |
| Identity map capped at 128 | An unbounded cache is a leak — 1.7 MB per 5000 rows | Raise cache_size, or refresh() |
| A rolled-back save stays in the cache | The cache doesn’t join the transaction | Clear Model._instances after a rollback |
| Not thread-safe | No locking; the registry and caches are shared | One connection and registry per thread |
That table is the most useful thing in the document. It’s also the hardest to write, because every row is an admission — and every row saves someone an afternoon and saves you an issue thread.
Limitations are a feature list in disguise
“No joins, no migrations, no async — on purpose” tells a reader more about whether to adopt your library than any feature list. Scope stated plainly reads as confidence; scope left vague reads as an unfinished project.
Docstrings answer the question the caller has
Not what the method is called. What it does that the signature doesn’t say.
def delete(self, connection: Connection) -> Self:
"""Remove this row. The object stays usable, and saving it inserts anew."""
The name says “delete”. The docstring says what happens to the object afterwards — which is the thing a caller can’t guess and will otherwise discover by experiment.
@classmethod
def get_many(cls, connection: Connection, ids: Sequence[int]) -> dict[int, Self]:
"""Every row in one query, instead of one query per row.
Anything already in the identity map is served from there, so a second
call for the same ids costs nothing.
"""
Compare with what this docstring could have said — “Get many objects by id” — which adds nothing to the signature.
Three habits:
- Lead with the one-line behaviour, in the imperative.
- Say what the caller can’t see: side effects, what’s cached, what’s validated, what it raises and when.
- Don’t restate types that annotations already carry.
def dedupe(ids):
"""Remove duplicates, keeping the order of first appearance.
>>> dedupe([3, 1, 3, 2, 1])
[3, 1, 2]
>>> dedupe([])
[]
"""
return list(dict.fromkeys(ids))
import doctest
results = doctest.testmod(verbose=False)
print(f"doctests run: {results.attempted}, failed: {results.failed}")
print("\nNow with a wrong example:")
def broken(ids):
"""Remove duplicates.
>>> broken([3, 1, 3])
[1, 3]
"""
return list(dict.fromkeys(ids))
print(doctest.run_docstring_examples(broken, {"broken": broken}, verbose=False) or "")doctest runs the examples in your docstrings and fails when they stop being
true. That makes examples the only part of documentation that can’t quietly
rot — worth using for small, pure functions with obvious inputs. It fits
dedupe and not save, which needs a database.
A decision record for the why
Docstrings say what the code does. The README says what the library promises. Neither says why you chose this over the obvious alternative, and that’s the question your future self — or the next maintainer — will actually have.
An architecture decision record is a short file per decision, and it needs four parts:
# 3. Validate rows loaded from the database
## Status
Accepted (Day 3)
## Context
Rows can be written by anything: another service, a migration, a shell. SQLite
has type affinity rather than type enforcement, so a column declared `integer`
can hold the string 'lots'. Our field annotations claim `post.views` is an
`int`, and a checker believes them.
## Decision
`get` and `get_many` build objects through `cls(**row)`, so every value passes
through the same field validation as a caller's assignment. No fast path may
skip it.
## Consequences
- A corrupt row raises `ValidationError` on load rather than `TypeError` later,
far from the cause.
- Loading is measurably slower: validation is roughly a third of the CPU in a
load-heavy profile.
- Callers cannot load and inspect a bad row through the library at all. They
need raw SQL to see it, which is a real cost for anyone debugging data.
The section that earns its place is Consequences, and specifically the bad ones. A decision record listing only benefits is marketing; the value is in having written down what you gave up, so that when the cost shows up later nobody relitigates the decision from scratch.
Three or four of these are enough for a library this size: the identity map, validating on load, the exception hierarchy, driver registration.
Write the README's limitation table
Below is a small library with four behaviours a user will be surprised by. Find them by reading the code, then write the table. This exercise has no tests — the check is the review below.
from collections import OrderedDict
_cache = OrderedDict()
MAX_ENTRIES = 100
class Feed:
"""Reads items from a source, remembering what it has seen."""
def __init__(self, source, *, page_size=50):
self.source = source
self.page_size = page_size
def items(self, since=None):
"""Return items newer than `since` (a timestamp), newest first."""
key = (self.source, since)
if key in _cache:
return _cache[key]
items = sorted(self._read(since), key=lambda item: -item["at"])
_cache[key] = items
while len(_cache) > MAX_ENTRIES:
_cache.popitem(last=False)
return items
def _read(self, since):
page = self.source.fetch(limit=self.page_size)
return [item for item in page if since is None or item["at"] > since]
def mark_read(self, item):
item["read"] = True
return item
print("Read the code. What would surprise a user?")Hint 1: the idea
Read it as the user who’ll call items() and mark_read(). For each line, ask
what the caller will assume that isn’t true. Four places to look: how many
items one call can return; how long a result stays the same; who else shares
the cache; and what the caller gets back — a copy, or the thing itself.
Hint 2: a first step
Start in _read: in what order are limit and since applied? Then trace
items() called twice with the same arguments. Which object does the second
call hand back — and what does mark_read then do to it?
Show one solution
Four, and the fourth is the one people miss.
| Limitation | Why | Workaround |
|---|---|---|
items() returns at most page_size items | _read fetches one page and filters it; since is applied after the limit, so an old since can return nothing even when matching items exist | Pass a larger page_size, or page at the call site |
| Results are cached and never expire within a run | _cache is keyed by (source, since) and only evicted by size | Use a fresh since, or clear _cache |
The cache is shared by every Feed in the process | _cache is a module-level dict, not per instance | One process per tenant, or key by something unique |
items() shares the cached records themselves | It returns the cached list, and mark_read mutates the dicts inside it in place | Return deep copies, or make the records immutable — a tuple or shallow copy is not enough |
The fourth is the real bug and the hardest to see: items() hands out the
cached list, and mark_read mutates the dicts inside it. Note what does not
fix it — returning tuple(items) or list(items). Those copy the outer
container, but the elements are the same dict objects, so mark_read still
reaches through and mutates the cached record. Isolating it means copying the
records themselves (copy.deepcopy, or building fresh dicts) or making them
immutable. That’s Day 1’s lesson, exactly — shallow copies share their
contents — arriving as a production incident.
Notice what the table does for each row: names the surprise, says what in the code causes it, and gives the caller something to do. A limitation without a workaround is a complaint; with one, it’s documentation.
Writing this table is also how you find out which limitations you’re not
willing to ship. Two of these four are worth fixing rather than documenting —
returning deep copies of the records, and applying since before the limit —
and the table is what makes that obvious.
What you learned
- A README should answer “can this do what I need, and what will it cost me?” before it teaches anything.
- A table of limitations with workarounds is the highest-value section you can write, and the hardest.
- Docstrings say what the signature can’t: side effects, caching, validation, errors.
- A decision record keeps the why, and its Consequences section must include what you gave up.