Models¤
A tinydantic document model is a full Pydantic model, so everything Pydantic offers — rich field types, validators, defaults, serialization — works unchanged. This page shows real-world models round-tripping through storage: datetime, UUID, enums, and nested models going in as Python objects and coming back out as the same typed values, plus validators, defaults, and a look at what actually lands in storage.
The examples share an in-memory database. Run them in order.
>>> from tinydb import TinyDB
>>> from tinydb.storages import MemoryStorage
>>> db = TinyDB(storage=MemoryStorage)
Rich field types round-trip¤
Declare fields with any JSON-serializable Pydantic type. Here a Task mixes a datetime, a UUID, an Enum, and a list of nested pydantic.BaseModel instances.
>>> import datetime
>>> import enum
>>> import uuid
>>> from pydantic import BaseModel
>>> from tinydantic import TinydanticModel
>>> class Priority(enum.Enum):
... LOW = 'low'
... HIGH = 'high'
>>> class Tag(BaseModel):
... label: str
... weight: int
>>> class Task(TinydanticModel, database=db, table_name='tasks'):
... title: str
... created: datetime.datetime
... ref: uuid.UUID
... priority: Priority
... tags: list[Tag] = []
Insert an instance built from real Python objects:
>>> Task(
... title='Ship docs',
... created=datetime.datetime(2026, 7, 5, 9, 30),
... ref=uuid.UUID('12345678-1234-5678-1234-567812345678'),
... priority=Priority.HIGH,
... tags=[Tag(label='urgent', weight=5)],
... ).insert()
Task(id=1, title='Ship docs', created=datetime.datetime(2026, 7, 5, 9, 30), ref=UUID('12345678-1234-5678-1234-567812345678'), priority=<Priority.HIGH: 'high'>, tags=[Tag(label='urgent', weight=5)])
Fetch it back and the fields are the same typed values, not strings — Pydantic validates the stored primitives back into datetime, UUID, Priority, and Tag objects:
>>> task = Task.get(Task.title == 'Ship docs')
>>> task.created
datetime.datetime(2026, 7, 5, 9, 30)
>>> task.ref
UUID('12345678-1234-5678-1234-567812345678')
>>> task.priority
<Priority.HIGH: 'high'>
>>> task.tags[0]
Tag(label='urgent', weight=5)
Validators reject bad data — including on load¤
A field_validator runs both when you construct a model and when you read one from storage, so it guards the boundary in both directions. Consider an Account whose email must contain an @:
>>> from pydantic import field_validator
>>> class Account(TinydanticModel, database=db, table_name='accounts'):
... email: str
...
... @field_validator('email')
... @classmethod
... def _check_email(cls, value: str) -> str:
... if '@' not in value:
... raise ValueError('email must contain @')
... return value
Constructing with a bad value raises before anything reaches storage:
>>> Account(email='not-an-email')
Traceback (most recent call last):
...
pydantic_core._pydantic_core.ValidationError: 1 validation error for Account
email
Value error, email must contain @ ...
The same guard fires on the way out. Suppose a malformed document already exists in the table — written before the validator existed, or by another tool — with an email that has no @:
>>> db.table('accounts').insert({'email': 'broken'})
1
>>> Account.all()
Traceback (most recent call last):
...
pydantic_core._pydantic_core.ValidationError: 1 validation error for Account
email
Value error, email must contain @ ...
Tip
Validation on load means a tinydantic read is a schema check, not just a fetch. Bad data surfaces as a loud pydantic.ValidationError the moment you read it, rather than silently flowing into your application as an untyped dict.
Defaults and default_factory¤
Ordinary Pydantic defaults and default factories work as usual. Fields you omit are filled in at construction time and then stored:
>>> from pydantic import Field
>>> class Session(TinydanticModel, database=db, table_name='sessions'):
... user: str
... token: uuid.UUID = Field(
... default_factory=lambda: uuid.UUID('00000000-0000-0000-0000-000000000001'),
... )
... active: bool = True
>>> Session(user='alice').insert()
Session(id=1, user='alice', token=UUID('00000000-0000-0000-0000-000000000001'), active=True)
The defaults are persisted, so a later read returns the filled-in values:
>>> Session.get(Session.user == 'alice')
Session(id=1, user='alice', token=UUID('00000000-0000-0000-0000-000000000001'), active=True)
What's actually stored¤
tinydantic serializes documents with model_dump(mode="json", exclude={"id"}) before handing them to TinyDB. "JSON mode" means every rich type is reduced to a JSON-safe primitive — a datetime becomes an ISO 8601 string, a UUID becomes its string form, an Enum becomes its value, and nested models become plain dicts. Reach past tinydantic to the raw TinyDB table with get_table() to see it:
>>> Task.get_table().get(doc_id=1)
{'title': 'Ship docs', 'created': '2026-07-05T09:30:00', 'ref': '12345678-1234-5678-1234-567812345678', 'priority': 'high', 'tags': [{'label': 'urgent', 'weight': 5}]}
This is the design that makes round-tripping work with any TinyDB storage backend, including plain JSON files: nothing but JSON primitives ever reaches the storage layer, and model_validate reconstructs the rich types on the way back. The id field is deliberately absent from the stored body — it maps to TinyDB's own doc_id (see the CRUD tour and Configuration).
Note
Because storage only ever sees JSON primitives, any model that is JSON-serializable round-trips through tinydantic faithfully. A type Pydantic cannot serialize to JSON will raise when you try to insert it — surface it as a JSON-safe representation (or a custom serializer) instead.
Where next¤
- Queries — build query conditions from model fields, including nested ones.
- Storage — choose a backend and persist your documents to disk.
- Configuration — bind models to a database and table, and how config resolves across a class hierarchy.