Repository navigation
Description
patch() — the recommended verb — costs three storage reads where save() costs one.
What happens
Storage operations per call, counted with a Storage subclass that counts read()/write():
| Call | Storage reads | Writes |
|---|---|---|
instance.save() |
1 | 1 |
instance.replace() |
1 | 1 |
instance.patch(...) |
3 | 1 |
Model.update({...}, cond) |
1 | 1 |
Model.update_by_ids({...}, [id]) |
2 | 1 |
patch() reads once for the composite-constraint merge, once in the existence check, and once inside the write cycle. On a revisioned model it reads a fourth time to absorb the rotated token.
On a JSON file with 2,000 documents that measured 2.63 ms/call for patch() vs 1.19 ms/call for save() — 2.2× slower.
Why it matters
The CRUD tour says "Reach for patch() when you mean 'change these fields of this document'" and fastapi.md recommends it for PATCH endpoints. That is the right semantic recommendation — the lost-update argument is correct — but it is also the slowest instance-level path, and nothing says so. Under FastAPI Pattern A that translates directly into event-loop stall.
What to do
The three reads are individually justified but collectively redundant: the write cycle already reads the table and could supply the stored body for both the existence check and the constraint merge. Folding them into one cycle would make patch() cost the same as save(), which is what its recommended status deserves.
Important
The existence check must move, not be dropped. TinyDB 4.9.0 changed Table.update(doc_ids=…) / Table.remove(doc_ids=…) to silently skip ids that are not in the table and return only the ids they touched (upstream #591); before 4.9.0 they raised KeyError. So a miss is no longer reported upstream at all, and a batch naming a missing id would become a partial write that looks successful. The existence check is what keeps these paths all-or-nothing — fold it inside the write cycle, where the table is already in hand. See Upstream limitations.
Reproduction
from tinydb import TinyDB
from tinydb.storages import MemoryStorage
from tinydantic import TinydanticModel
from tinydantic.tinydb.middlewares import ProfilingMiddleware
storage = ProfilingMiddleware(MemoryStorage)
db = TinyDB(storage=storage)
class Book(TinydanticModel, database=db):
title: str
book = Book(title="a").insert()
storage.reset()
book.save()
print("save(): ", storage.read_count, "storage reads")
storage.reset()
book.patch(title="b")
print("patch(): ", storage.read_count, "storage reads")Run with python repro.py.
Actual output:
save(): 1 storage reads
patch(): 3 storage reads
Expected: patch() costs one read, like save() — the write cycle already has the table in hand and can supply the stored body to both the existence check and the constraint merge.
Environment: tinydantic 0.4.0 at 049f261, Python 3.10.20, TinyDB 4.9.0, pydantic 2.13.4, macOS. Run 2026-08-13, using the ProfilingMiddleware added in #141.
Re-measured at e7d0ed0 on 2026-08-12. Originally finding M6 of the 2026-08-06 internal review.
Assisted-by: Claude:claude-opus-5
Activity
feat(tinydb): add ProfilingMiddleware for storage operation counts