Skip to content

patch() costs three storage reads where save() costs one #123

Open

Description

@cdwilson

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

  1. added this to the 0.5.0 milestone on Aug 13, 2026
  2. added
    enhancementNew feature or request
    performanceCost or complexity-class findings
    ai-assistedCreated or investigated with help from an AI agent
    on Aug 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    ai-assistedCreated or investigated with help from an AI agentenhancementNew feature or requestperformanceCost or complexity-class findings

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions