Cendry¶
A typed Firestore ODM for Python. Define models, query with Python operators, get full IDE support.
Define once, query naturally¶
from cendry import Cendry, Increment
with Cendry() as ctx:
# Save (upsert)
city = City(name="SF", state="CA", population=870_000)
ctx.save(city) # auto-generates ID
# Partial update
ctx.update(city, {"population": Increment(1000)})
# Atomic batch
with ctx.batch() as batch:
batch.save(city1)
batch.delete(city2)
# Transaction with auto-retry
def transfer(txn):
src = txn.get(City, "SF")
txn.update(src, {"population": src.population - 100})
ctx.transaction(transfer)
What is Firestore?¶
Cloud Firestore is a NoSQL document database from Google — serverless, scalable, strongly consistent. Cendry is a typed ODM (Object-Document Mapper) that lets you work with Firestore using Python classes instead of raw dicts. Learn more →
Why Cendry?¶
Type-safe from definition to query. Field[T] annotations are validated at class definition time. Your IDE knows every field, every filter method, every return type.
Python operators, not strings. Write City.population > 1_000_000 instead of FieldFilter("population", ">", 1000000). Compose with & and |.
Sync and async. Same API, same models. Cendry for sync, AsyncCendry for async. Powered by anyio — works with asyncio and trio.
Thin wrapper, not an abstraction. Cendry doesn't hide Firestore. FieldFilter is Firestore's own class. Query semantics match Firestore exactly.
Features at a glance¶
| Feature | Example |
|---|---|
| Typed models | name: Field[str] — validated at class definition |
| Python filters | City.state == "CA" — operators become Firestore filters |
| Chainable queries | .filter(...).order_by(...).limit(10).to_list() |
| Pagination | for page in query.paginate(page_size=20): |
| Batch fetch | ctx.get_many(City, ["SF", "LA", "NYC"]) |
| Field aliases | field(alias="displayName") — Python name ≠ Firestore name |
| Enum support | Field[Status] — auto-converts by value or name |
| Write operations | ctx.save(city), ctx.create(city), ctx.update(city, {...}), ctx.delete(city) |
| Batch writes | ctx.save_many([...]), with ctx.batch() as b: — atomic, max 500 |
| Transactions | ctx.transaction(fn) — auto-retry, read-then-write atomicity |
| Optimistic locking | ctx.update(city, {...}, if_unchanged=True) — precondition-based |
| Serialization | from_dict(City, {...}) and to_dict(city) |
| Auto-timestamps | field(auto_now=True) — automatic creation/update times |
| Built-in type handlers | Field[Decimal], Field[datetime.date], Field[datetime.time] — just work |
| Custom types | register_type(Money, deserialize=...) |
| Datastore migration | Cendry(backend=DatastoreBackend(...)) — validate, then swap |
| Context manager | with Cendry() as ctx: — auto-closes client |
Migrating from Datastore mode?¶
Cendry is a Firestore Native mode ODM. If your project is still on Firestore in Datastore mode, Cendry can help you migrate — define your models once, validate against existing data, then switch backends with a one-line change.
# Step 1: Work with your existing Datastore data
from cendry.backends.datastore import DatastoreBackend
db = Cendry(backend=DatastoreBackend(project="my-project"))
cities = db.select(City).to_list() # reads from Datastore
# Step 2: After migrating the database to Native mode
db = Cendry() # that's it — same models, same queries
The Datastore backend supports the common subset (CRUD, queries, batch, transactions) and raises clear errors for Native-only features — each one a reason to migrate. Migration guide →
Get started¶
New to Cendry? Start with the First Steps tutorial — install, define a model, run your first query in 5 minutes.
Know what you need? Jump to a How-To Guide — practical recipes for models, filtering, aliases, async, and more.
Looking for specifics? Check the API Reference — every class, method, and parameter documented.
Want to understand the design? Read the Explanation — architecture and design decisions.
Python >= 3.13 · Built on google-cloud-firestore and anyio · GitHub