Skip to content

shadowed_fields= accepts names tinydantic dispatches on instances, breaking reads and writes at a distance #151

Open

Description

@cdwilson

What happens

ShadowedFieldError offers the same remedy for every reserved name: rename the field, or list it in shadowed_fields= and query it with field(). For most of the ~60 names that contract holds — the only cost of opting out is the Model.field query sugar. But three names are methods tinydantic itself calls on instances during reads and writes: after_read, before_write, and to_tinydb_document. A pydantic field shadows only at the instance level, so opting out on one of these hands the machinery a field value where it expects a callable:

  • A field named after_read breaks every read: from_tinydb_document() ends with instance.after_read(), which is now a string.
  • A field named to_tinydb_document breaks every write: insert(), save(), replace(), upsert(), and insert_many() all serialize via self.to_tinydb_document().
  • A field named before_write is the treacherous one: insert() and save() work, but patch() raises.

The before_write split is an accident of a performance guard, not a decision. _before_write_whole_model() skips the hook when the model never overrode it, and its check — type(self).before_write is TinydanticModel.before_write — is a class-level comparison that instance shadowing cannot affect, so whole-model writes never reach the field. patch() calls _run_before_write() unguarded (guarding would save nothing there: the mapping the hook receives already exists), and that method begins with self.before_write(fields) — instance lookup, field value, TypeError.

Why it matters

The opt-out's stated cost is "query sugar is off for this name". For these three names the actual cost is "the model stops reading, or stops writing — possibly only on the code path you didn't test". A model with a before_write field can ship, insert and save correctly in production, and fail on its first patch() with a bare TypeError that names neither the field nor tinydantic.

What to do

Refuse the internally-dispatched names in shadowed_fields=: raise at class definition even when the name is listed, saying that renaming is the only option for these. The alternative — adding override-guards so a shadowed hook is skipped everywhere — makes the field appear to work while silently disabling any genuinely inherited hook, and every future instance-dispatched method would need to remember its own guard. Refusal is one list in one place.

(insert, save, delete, replace, and patch are also instance methods, but only user code calls them and the class-call form Model.save(doc) keeps working, so whether they stay opt-outable is a separate, softer question.)

Reproduction

import warnings

from tinydb import TinyDB
from tinydb.storages import MemoryStorage

from tinydantic import TinydanticModel

warnings.filterwarnings("ignore")
db = TinyDB(storage=MemoryStorage)


class Hooked(TinydanticModel, database=db, shadowed_fields=("after_read",)):
    after_read: str


Hooked.get_table().insert({"after_read": "x"})
try:
    Hooked.all()
except TypeError as e:
    print("read with an after_read field:", e)


class Patched(TinydanticModel, database=db, shadowed_fields=("before_write",)):
    before_write: str
    n: int = 0


doc = Patched(before_write="x").insert()
print("insert() with a before_write field: worked, id =", doc.id)
try:
    doc.patch(n=1)
except TypeError as e:
    print("patch() with a before_write field:", e)

Run with python repro.py (project venv).

Actual output:

read with an after_read field: 'str' object is not callable
insert() with a before_write field: worked, id = 1
patch() with a before_write field: 'str' object is not callable

Expected: both class definitions fail with an error stating that after_read / before_write cannot be opted out and must be renamed — rather than models that build fine and fail path-dependently at run time.

Also verified, same shape, kept out of the script for brevity: with shadowed_fields=("to_tinydb_document",) and a field of that name, insert() raises TypeError: 'str' object is not callable.

Environment: tinydantic 0.4.0 at 389c6d9, Python 3.10.20, TinyDB 4.9.0, pydantic 2.13.4, macOS. Run 2026-08-14.


The reproductions were run as shown. No fix was built or tested. Surfaced while working through the conflict-handling story for #121.

Assisted-by: Claude:claude-fable-5

Activity

  1. added
    bugSomething isn't working
    api-designShape of the public surface
    ai-assistedCreated or investigated with help from an AI agent
    on Aug 14, 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 agentapi-designShape of the public surfacebugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions