Repository navigation
Description
FindQuery.delete() has two paths, and its docstring said an empty window costs zero storage writes on both. The condition-only path costs one full write.
What happens
FindQuery.delete() checks the condition-only fast path before the empty-window check:
if self._cond is not None and not self._has_modifiers():
return self._model.remove(self._cond)
ids = self._resolved_ids()
if not ids:
return []The fast path delegates to TinydanticModel.remove(cond) → Table.remove(cond=…) → Table._update_table, which calls self._storage.write(tables) unconditionally — matched or not. The resolved-ids path returns before writing anything.
So Model.find(cond).delete() on a condition that matches nothing rewrites the whole table, while Model.find(cond).limit(5).delete() on the same condition does not.
The same asymmetry applies to TinydanticModel.remove(cond) directly. tinydantic already has the machinery to avoid it: _run_write_cycle() aborts through _NothingMatchedError precisely so "a no-match write then costs one read and zero writes, matching the public no-match paths" — but remove() only routes through it for id conditions.
Why it matters
On JSONStorage a full write is the dominant cost of any operation, and a no-match delete is a common shape (a cleanup pass over a queue that happens to be empty; a tenant-scoped purge for a tenant with no rows). ProfilingMiddleware (added in #141) makes the difference measurable, which is how this surfaced.
It is also an internal inconsistency: FindQuery.delete()'s two paths are documented as "observably identical", and with a storage-operation counter in the library they are not.
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 User(TinydanticModel, database=db):
name: str
User(name="alice").insert()
storage.reset()
print("condition only, no match:", User.find(User.name == "nobody").delete())
print(" reads", storage.read_count, "writes", storage.write_count)
storage.reset()
print("with modifier, no match: ", User.find(User.name == "nobody").limit(5).delete())
print(" reads", storage.read_count, "writes", storage.write_count)Run with python repro.py.
Actual output:
condition only, no match: []
reads 1 writes 1
with modifier, no match: []
reads 1 writes 0
Expected: writes 0 on both. Nothing matched, so nothing needs writing.
Environment: tinydantic 0.4.0 at 39b616c, Python 3.10.20, TinyDB 4.9.0, pydantic 2.13.4, macOS. Run 2026-08-13.
What to do
Route TinydanticModel.remove(cond) through _run_write_cycle() for every condition, not just id conditions. The updater already returns the matched ids and _NothingMatchedError already aborts the cycle before the storage write, so the no-match case becomes one read and zero writes — and FindQuery.delete()'s two paths become genuinely identical.
Weigh that against the cost: the fast path currently hands the condition to TinyDB, which evaluates it against raw bodies; routing through the write cycle wraps each body in a Document. That is the same trade the id-condition path already makes.
What was not done
The finding is verified and the reproduction was run. No fix was built or tested; the Document-wrapping cost of the proposed change was not measured.
Found as finding F1 of the 2026-08-13 docs and docstring audit. The docstring has been corrected on the audit branch to describe what actually happens and to point here; this issue tracks making it zero.
Assisted-by: Claude:claude-opus-5
Activity
docs: correct the find, errors, fields, and storage docstrings