FastAPI¤
A tinydantic model is a Pydantic model, so it drops straight into FastAPI as a response model — the same class that reads and writes your documents also describes the JSON your API returns. This page builds a small CRUD API for a User resource, exercises every endpoint with FastAPI's TestClient, and closes with the guidance that matters when you put TinyDB behind an async framework.
A small CRUD API¤
The setup below is everything the API needs: an in-memory database (see Storage), the User model that doubles as the response model, a plain Pydantic UserCreate schema for request bodies, and three endpoints. Because a tinydantic model keeps its id field in model_dump(), responses include the assigned id for free — no separate output schema required.
>>> from fastapi import FastAPI, HTTPException, status
>>> from fastapi.testclient import TestClient
>>> from pydantic import BaseModel
>>> from tinydb import TinyDB
>>> from tinydb.storages import MemoryStorage
>>> from tinydantic import TinydanticModel
>>>
>>> db = TinyDB(storage=MemoryStorage)
>>>
>>> class User(TinydanticModel, database=db, table_name='users'):
... name: str
... email: str
>>>
>>> class UserCreate(BaseModel):
... name: str
... email: str
>>>
>>> app = FastAPI()
>>>
>>> @app.post('/users', status_code=status.HTTP_201_CREATED)
... def create_user(payload: UserCreate) -> User:
... return User(**payload.model_dump()).insert()
>>>
>>> @app.get('/users/{user_id}')
... def read_user(user_id: int) -> User:
... user = User.get_by_id(user_id)
... if user is None:
... raise HTTPException(status_code=404, detail='User not found')
... return user
>>>
>>> @app.get('/users')
... def list_users() -> list[User]:
... return User.all()
>>>
>>> client = TestClient(app)
A few things worth calling out:
- The request body is a separate schema.
UserCreatehas noid, so clients cannot set one — the server assigns it. Returning aUser(which does exposeid) means the response echoes the created resource, id included. get_by_idreturnsNonewhen nothing matches, which the handler turns into a404. This is the precisely-typed read variant from the CRUD tour; it returnsUser | None, so theis Nonecheck is exactly what a type checker expects.- The endpoints are plain
def, notasync def— the deliberate choice explained in the last section.
POST /users — create¤
Posting a body creates the document and returns it with id populated and a 201 Created status:
>>> response = client.post('/users', json={'name': 'Ada', 'email': 'ada@example.com'})
>>> response.status_code
201
>>> response.json()
{'id': 1, 'name': 'Ada', 'email': 'ada@example.com'}
A second create is assigned the next id:
>>> client.post('/users', json={'name': 'Grace', 'email': 'grace@example.com'}).json()
{'id': 2, 'name': 'Grace', 'email': 'grace@example.com'}
GET /users/{user_id} — read one¤
Fetching an existing id returns the document; the id in the path maps straight to TinyDB's document id:
>>> response = client.get('/users/1')
>>> response.status_code
200
>>> response.json()
{'id': 1, 'name': 'Ada', 'email': 'ada@example.com'}
A missing id returns 404 with the handler's detail message, because get_by_id returned None:
>>> response = client.get('/users/999')
>>> response.status_code
404
>>> response.json()
{'detail': 'User not found'}
GET /users — list all¤
The collection endpoint returns every document via all(), serialized through the User response model:
>>> response = client.get('/users')
>>> response.status_code
200
>>> response.json()
[{'id': 1, 'name': 'Ada', 'email': 'ada@example.com'},
{'id': 2, 'name': 'Grace', 'email': 'grace@example.com'}]
Async, FastAPI, and TinyDB¤
TinyDB is a synchronous library, and tinydantic is a thin synchronous layer over it. That is not a limitation to work around in FastAPI — it is the grain to work with.
Prefer plain def endpoints. When you declare a path operation with def (not async def), FastAPI runs it in an external threadpool so its blocking I/O never stalls the event loop. This is the correct default for TinyDB-backed handlers: your def create_user above is already doing the right thing, with no extra code. The endpoints in this page are all plain def for exactly this reason.
In async def contexts, offload the blocking call. If you are inside an async def handler (perhaps because it also awaits other async work), do not call tinydantic methods directly — a blocking call on the event-loop thread stalls every other request. Push it to a worker thread with asyncio.to_thread:
import asyncio
from fastapi import FastAPI
app = FastAPI()
@app.get("/users/{user_id}")
async def read_user(user_id: int):
# Runs the blocking TinyDB read off the event loop.
user = await asyncio.to_thread(User.get_by_id, user_id)
if user is None:
raise HTTPException(status_code=404, detail="User not found")
return user
TinyDB has no concurrency safety. TinyDB does no locking and is not safe for concurrent access — neither across processes nor across threads writing at the same time. Two practical rules follow:
- Run a single process. Do not put a TinyDB file behind multiple worker processes (for example
uvicorn --workers 4or several Gunicorn workers). Each process holds its own view of the file and they will clobber one another. Use one process — scale with the threadpool, not with processes. - Serialize writes. Route writes through one place. FastAPI's threadpool can run several
defhandlers at once, so guard write paths with a lock (or funnel them through a single worker) if concurrent writes are possible.
Note
tinydantic's recommendation is sync-first, deliberately. The async lifecycle shims that exist for TinyDB are unmaintained, are async with-scoped in a way that conflicts with tinydantic's long-lived class binding, and rely on file locking that is silently absent on some platforms. Plain def handlers in FastAPI's threadpool, plus asyncio.to_thread where you genuinely need it, cover the real use cases without that risk. Revisit if a future version adds first-class async support.
None of this is a compromise for tinydantic's intended audience.
Tip
These are the same properties that make TinyDB ideal for what tinydantic targets: prototypes, small tools, tests, and single-process services where a JSON file is the whole database. If you outgrow a single process, that is the signal to move to a client/server database — not to bolt concurrency onto TinyDB.
Where next¤
- Testing — the
bind()and application-factory patterns used to point these models at an in-memory database in tests. - CRUD tour — the
insert(),get_by_id(), andall()methods these endpoints are built on. - Configuration — binding models to a database, including late binding in an app factory.