Repository navigation
Description
Failed writes leave the instance mutated.
What happens
insert() runs before_write() and rotates the revision token before attempting the write. If the write then fails — duplicate id, unique violation, validation error — the instance keeps the mutated fields and a token that was never persisted.
Verified at HEAD with a model whose before_write() stamps a field, inserting a duplicate value on a Unique() field:
raised UniqueConstraintError
instance stamp after failed insert: 999 (0 = untouched)
table contents: [{'email': 'dup@x', 'stamp': 999}] <- the FIRST row only; nothing new was written
Storage is correct — nothing was written. The instance is not: it now carries stamp=999, produced by a hook that ran for a write that never happened.
insert_many() is the same shape at batch scale: it mutates every instance in the batch before validating any of them, so a batch that fails on its last document leaves all of them stamped.
Why it matters
"Nothing was written" and "nothing was changed" are not the same guarantee, and the docs imply the stronger one. The practical bite is the retry path: catch the error, fix the duplicate, insert again — and the instance now carries a before_write() result from the failed attempt, plus a revision token that does not match anything in storage.
Not dangerous today, but it is the kind of gap that becomes a real bug once someone builds a retry loop on top of it.
What to do
Either stage the mutations and apply them only after the write succeeds, or state the weaker guarantee explicitly in the lifecycle-hooks documentation. Staging is the better answer for insert_many(), where the current order means one bad document mutates the whole batch.
Reproduction
from collections.abc import Mapping
from typing import Annotated, Any
from tinydb import TinyDB
from tinydb.storages import MemoryStorage
from tinydantic import TinydanticModel, Unique, UniqueConstraintError
db = TinyDB(storage=MemoryStorage)
class User(TinydanticModel, database=db):
email: Annotated[str, Unique()]
stamp: int = 0
def before_write(self, fields: Mapping[str, Any]) -> Mapping[str, Any]:
return {"stamp": 999}
User(email="[email protected]").insert()
dup = User(email="[email protected]")
try:
dup.insert()
except UniqueConstraintError:
print("insert raised UniqueConstraintError")
print("dup.stamp:", dup.stamp)
print("table: ", db.table("user").all())Run with python repro.py.
Actual output:
insert raised UniqueConstraintError
dup.stamp: 999
table: [{'email': '[email protected]', 'stamp': 999}]
Storage is correct — the table holds only the first row, nothing new was written. The instance is not: dup.stamp is 999, produced by a hook that ran for a write that never happened. (0 is the untouched value.)
Expected: dup.stamp is 0 after the failed insert — the hook's contribution is staged and applied only once the write succeeds.
Environment: tinydantic 0.4.0 at 4d373d1, Python 3.10.20, TinyDB 4.9.0, pydantic 2.13.4, macOS. Run 2026-08-13.
Reproduced at e7d0ed0 on 2026-08-12. Originally finding L4 of the 2026-08-06 internal review.
Assisted-by: Claude:claude-opus-5
Activity