Skip to content

upsert() silently discards document.id when a condition is passed #120

Closed

Description

@cdwilson

upsert() silently discards document.id when a condition is passed, and then overwrites it.

What happens

The same instance produces two different outcomes depending on whether a condition is given:

x = A(id=7, name="seven")
A.upsert(x)                     # -> [7]   x.id == 7   (id honored)

y = A(id=7, name="seven")
A.upsert(y, A.name == "nope")   # -> [8]   y.id == 8   (id discarded)

With no condition, upsert() serializes with force_dict=False, producing a TinyDB Document that carries doc_id=7, so the insert lands at id 7. With a condition, it serializes with force_dict=True, which drops the id, so the insert gets a fresh one — and document.id is then overwritten with that fresh value, so even the evidence that the caller asked for id 7 disappears.

Why it matters

Setting id explicitly means "put it here" — that is the entire point of the field. Silently ignoring it and then destroying the record of the request is the failure mode this project otherwise works hard to eliminate. The docstring covers the condition's id ("the insert does not adopt the condition's id value") but says nothing about the document's own id being dropped.

Options

  1. Honor document.id in both branches — consistent, and matches what insert() does.
  2. Raise when a condition is passed alongside a set id and nothing matches.

Either way it needs documenting; this is exactly the kind of sharp edge the CRUD tour is otherwise good at calling out.

Notes

TinyDB 4.9.0 rewrote the internals this path runs through (Table.upsert() now checks for an empty update() return instead of catching KeyError), but the divergence lives in tinydantic's own force_dict choice, so upstream neither fixes nor worsens it.

Reproduction

from tinydb import TinyDB
from tinydb.storages import MemoryStorage

from tinydantic import TinydanticModel

db = TinyDB(storage=MemoryStorage)


class Book(TinydanticModel, database=db):
    title: str


no_cond = Book(id=7, title="a")
print("no condition:  ", Book.upsert(no_cond), "instance id:", no_cond.id)

with_cond = Book(id=7, title="b")
print("with condition:", Book.upsert(with_cond, Book.title == "nope"),
      "instance id:", with_cond.id)

Run with python repro.py.

Actual output:

no condition:   [7] instance id: 7
with condition: [8] instance id: 8

Expected: the second call honors id=7 the way the first does — or refuses the call — instead of inserting at 8 and overwriting with_cond.id with the id the caller did not ask for.

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 M7 of the 2026-08-06 internal review.

Assisted-by: Claude:claude-opus-5

Metadata

Metadata

Assignees

No one assigned

    Labels

    ai-assistedCreated or investigated with help from an AI agentapi-designShape of the public surfacebugSomething isn't working

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions