Comparisons¶
This page covers two topics: how Firestore's two database modes differ at the engine level, and how the available Python client libraries compare for day-to-day use.
Firestore Native Mode vs Datastore Mode¶
Firestore runs in two modes that share the same storage engine but expose different APIs and capabilities. Understanding these differences matters when choosing a mode for new projects or migrating existing ones.
Identity¶
| Aspect | Native Mode | Datastore Mode |
|---|---|---|
| ID type | String only | String name or 64-bit integer |
| Auto-generation | Random 20-character string | Auto-allocated integer or string |
| Key structure | Document path (collections/docId/subcollections/docId) |
Ancestor key chain (Kind/id/Kind/id) |
Data model¶
| Aspect | Native Mode | Datastore Mode |
|---|---|---|
| Terminology | Documents and collections | Entities and kinds |
| Nesting | Subcollections under documents | Ancestor paths (parent keys) |
| Grouping | Collection group queries across subcollections | Ancestor queries within a key hierarchy |
Data types¶
Both modes share most value types: string, integer, float, boolean, timestamp, bytes, geopoint, array, map, and null.
| Aspect | Native Mode | Datastore Mode |
|---|---|---|
| References | Document references (path-based) | Key references (ancestor-chain-based) |
Queries¶
| Capability | Native Mode | Datastore Mode |
|---|---|---|
| Equality / range filters | Yes | Yes |
| OR filters | Yes | Yes (modern Firestore-backed) |
!= and NOT_IN |
Yes | Yes (modern Firestore-backed) |
| Collection group queries | Yes | No |
| Real-time listeners | Yes | No |
| Projection queries | Yes | Yes |
| Ancestor / parent queries | Subcollection path | Ancestor key queries |
Writes¶
| Capability | Native Mode | Datastore Mode |
|---|---|---|
| Create-only (fail on duplicate) | create() |
insert() |
| Partial update | Yes (native) | Yes |
Field transforms (Increment, SERVER_TIMESTAMP) |
Yes | No |
| TTL policies | Yes | No |
Transactions¶
| Capability | Native Mode | Datastore Mode |
|---|---|---|
| Max mutations per transaction | 500 | 500 (modern Firestore-backed) |
| Read-only transactions | Yes | No |
| Entity group limits | N/A | Removed in modern Firestore-backed Datastore |
Consistency¶
Both modes are strongly consistent for reads and queries. Datastore was eventually consistent for non-ancestor queries historically, but modern Firestore-backed Datastore is strongly consistent.
Limits¶
| Limit | Native Mode | Datastore Mode |
|---|---|---|
| Max document/entity size | 1 MiB | 1 MiB |
| Max field nesting depth | 20 levels | No explicit limit |
| Recommended write rate | 1 write/sec per document | 1 write/sec per entity |
Cendry targets Native mode
Cendry is built for Firestore in Native mode. If you're moving away from Datastore mode, see Migrate from Datastore to Native.
Library & API Comparison¶
Side-by-side comparison of common use cases across three approaches:
- Firestore SDK —
google-cloud-firestore, the official low-level client - NDB —
google-cloud-ndb, the legacy App Engine ORM (Datastore mode only) - Cendry — typed ODM for Firestore Native mode
NDB is deprecated for new projects
Cloud NDB only works with Firestore in Datastore mode — it does not support Firestore Native mode. Google recommends Firestore Native mode for all new applications. NDB is shown here for developers migrating from App Engine.
Define a model¶
from google.cloud import ndb
class City(ndb.Model):
name = ndb.StringProperty()
state = ndb.StringProperty()
population = ndb.IntegerProperty()
mayor = ndb.StructuredProperty(Mayor) # nested
No type annotations, no IDE autocomplete on fields.
No model layer — you work with raw dicts.
from cendry import Model, Map, Field, field
class Mayor(Map):
name: Field[str]
since: Field[int]
class City(Model, collection="cities"):
name: Field[str]
state: Field[str]
population: Field[int]
mayor: Field[Mayor | None] = field(default=None)
Type annotations validated at class definition. Full IDE support.
Read a document¶
Query with filters¶
from google.cloud.firestore_v1.base_query import FieldFilter
query = (
client.collection("cities")
.where(filter=FieldFilter("state", "==", "CA"))
.where(filter=FieldFilter("population", ">", 1_000_000))
.order_by("population", direction="DESCENDING")
.limit(10)
)
for doc in query.stream():
data = doc.to_dict()
print(data["name"], data["population"])
Save a document¶
Partial update¶
Delete a document¶
Batch writes¶
Transactions¶
from google.cloud.firestore_v1.transaction import transactional
transaction = client.transaction()
@transactional
def transfer(transaction, from_ref, to_ref, amount):
from_doc = transaction.get(from_ref).to_dict()
to_doc = transaction.get(to_ref).to_dict()
transaction.update(from_ref, {
"population": from_doc["population"] - amount,
})
transaction.update(to_ref, {
"population": to_doc["population"] + amount,
})
from_ref = client.collection("cities").document("SF")
to_ref = client.collection("cities").document("LA")
transfer(transaction, from_ref, to_ref, 1000)
Nested data (Maps)¶
Optimistic locking¶
NDB doesn't have built-in optimistic locking. You must use transactions for conflict resolution.
Summary¶
| Aspect | NDB (deprecated) | Firestore SDK | Cendry |
|---|---|---|---|
| Database | Datastore mode only | Native mode | Native mode |
| Model layer | ndb.Model — no type annotations |
None — raw dicts | Model + Field[T] — typed |
| Data access | instance.field — untyped |
dict["field"] — untyped |
instance.field — typed |
| Filters | City.state == "CA" |
FieldFilter("state", "==", "CA") |
City.state == "CA" |
| Partial update | Not supported | doc.update({...}) |
ctx.update(instance, {...}) |
| Nested data | StructuredProperty |
Nested dicts | Map class — typed |
| Optimistic lock | Not supported | LastUpdateOption(...) |
if_unchanged=True |
| Async | Not supported | AsyncClient |
AsyncCendry |
| IDE support | Minimal | None | Full autocomplete + type checking |
| Status | Legacy — Datastore only | Active — low-level | Active — typed ODM |
Complete feature matrix¶
Comprehensive feature-by-feature comparison. Green (✅) = supported, yellow (🔶) = partial, red (❌) = not supported.
Model & Schema¶
| Feature | NDB | Firestore SDK | Cendry | Notes |
|---|---|---|---|---|
| Typed model classes | 🔶 ndb.Model (no annotations) |
❌ Raw dicts | ✅ Model + Field[T] |
Cendry validates types at class definition |
| Nested structures | ✅ StructuredProperty |
🔶 Nested dicts | ✅ Map class |
Cendry maps are typed with IDE support |
| Field defaults | ✅ default= |
N/A | ✅ field(default=) |
|
| Field aliases | ❌ | N/A | ✅ field(alias=) |
Firestore name ≠ Python name |
| Enum fields | ❌ | ❌ Manual | ✅ Field[MyEnum] |
Auto-converts by value or name |
| Repeated/list fields | ✅ repeated=True |
✅ Arrays | ✅ Field[list[str]] |
|
| Computed properties | ✅ ComputedProperty |
❌ | ❌ | Derived read-only fields — not yet in Cendry |
| Expando (dynamic fields) | ✅ Expando |
✅ Any dict key | ❌ | Intentional — Cendry favors typed schemas |
| Property validators | ✅ validator=fn |
❌ | ❌ | Per-field validation callbacks — not yet in Cendry |
| Custom type handlers | ❌ | ❌ | ✅ register_type() |
Serialize/deserialize custom types |
| Type validation at definition | ❌ | ❌ | ✅ TypeRegistry |
Catches invalid types before runtime |
Read Operations¶
| Feature | NDB | Firestore SDK | Cendry | Notes |
|---|---|---|---|---|
| Get by ID | ✅ Model.get_by_id() |
✅ doc_ref.get() |
✅ ctx.get() |
Cendry raises DocumentNotFoundError |
| Find (None if missing) | ❌ Returns None | 🔶 Check .exists |
✅ ctx.find() |
|
| Batch get | ✅ ndb.get_multi() |
✅ client.get_all() |
✅ ctx.get_many() |
|
| Projection queries | ✅ projection=[...] |
✅ select([...]) |
✅ query.select("name") |
Fetch only specific fields |
| Distinct queries | ✅ distinct_on=[...] |
❌ Not in SDK | ❌ | Not supported by Firestore Python SDK |
| Collection groups | ❌ | ✅ collection_group() |
✅ ctx.select_group() |
NDB doesn't have this concept |
Query & Filtering¶
| Feature | NDB | Firestore SDK | Cendry | Notes |
|---|---|---|---|---|
| Python operators | ✅ City.state == "CA" |
❌ Strings only | ✅ City.state == "CA" |
|
| Composite filters (AND/OR) | 🔶 AND only | ✅ And/Or |
✅ And/Or + &/| |
|
| Chainable queries | ❌ | ❌ | ✅ .filter().order_by().limit() |
Immutable query builder |
| Ordering | ✅ .order() |
✅ .order_by() |
✅ .order_by(City.pop.desc()) |
|
| Limit | ✅ .fetch(limit) |
✅ .limit() |
✅ .limit() |
|
| Pagination | ✅ Cursor-based | ✅ Cursor-based | ✅ .paginate(page_size) |
Cendry doesn't export cursor tokens yet |
| Count | ❌ Must fetch all | ✅ .count() |
✅ .count() |
Firestore aggregation |
| Exists check | ❌ | ❌ Manual | ✅ .exists() |
|
| First result | ❌ .fetch(1) |
❌ Manual | ✅ .first() |
|
| Exactly one | ❌ | ❌ Manual | ✅ .one() |
Raises if 0 or >1 |
| Copy-pasteable repr | ❌ | ❌ | ✅ | All queries produce valid Python repr |
Write Operations¶
| Feature | NDB | Firestore SDK | Cendry | Notes |
|---|---|---|---|---|
| Save (upsert) | ✅ entity.put() |
✅ doc_ref.set() |
✅ ctx.save() |
Returns doc ID |
| Create (insert only) | ❌ | ✅ doc_ref.create() |
✅ ctx.create() |
Raises on duplicate |
| Partial update | ❌ Full overwrite | ✅ doc_ref.update() |
✅ ctx.update() |
Dot-notation, transforms |
| Delete | ✅ key.delete() |
✅ doc_ref.delete() |
✅ ctx.delete() |
must_exist= option |
| Refresh (re-fetch in-place) | ❌ | ❌ | ✅ ctx.refresh() |
Mutates instance |
| Auto-generate ID | ✅ Automatic | ✅ document() |
✅ Auto + mutates instance.id |
|
| Field validation on write | ❌ | ❌ | ✅ validate_required_fields |
|
| Firestore transforms | N/A | ✅ Increment, etc. |
✅ Re-exported | DELETE_FIELD, SERVER_TIMESTAMP, etc. |
Batch & Transactions¶
| Feature | NDB | Firestore SDK | Cendry | Notes |
|---|---|---|---|---|
| Batch writes | ✅ ndb.put_multi() |
✅ WriteBatch |
✅ ctx.save_many() / ctx.batch() |
Max 500, atomic |
| Batch delete | ✅ ndb.delete_multi() |
✅ WriteBatch |
✅ ctx.delete_many() |
|
| Mixed batch operations | ❌ | ✅ WriteBatch |
✅ ctx.batch() context manager |
save + update + delete |
| Transactions | ✅ @ndb.transactional |
✅ @transactional |
✅ ctx.transaction(fn) |
Auto-retry on contention |
| Transaction context manager | ❌ | ❌ | ✅ with ctx.transaction(): |
Single attempt |
| Transaction reads | ✅ Implicit | ✅ transaction.get() |
✅ txn.get() / txn.find() |
|
| Read-only transactions | ❌ | ✅ read_only=True |
✅ read_only=True |
Metadata & Concurrency¶
| Feature | NDB | Firestore SDK | Cendry | Notes |
|---|---|---|---|---|
| Document metadata | ❌ | ✅ DocumentSnapshot attrs |
✅ get_metadata() |
update_time, create_time |
| Optimistic locking | ❌ Use transactions | ✅ LastUpdateOption |
✅ if_unchanged=True |
Precondition-based |
| Real-time listeners | ❌ | ✅ on_snapshot() |
✅ query.on_snapshot(cb) |
Sync only (SDK limitation) |
Async Support¶
| Feature | NDB | Firestore SDK | Cendry | Notes |
|---|---|---|---|---|
| Async client | ❌ | ✅ AsyncClient |
✅ AsyncCendry |
|
| Async reads | ❌ | ✅ | ✅ | await ctx.get() |
| Async writes | ❌ | ✅ | ✅ | await ctx.save() |
| Async queries | ❌ | ✅ async for |
✅ async for |
|
| Async batch | ❌ | ✅ AsyncWriteBatch |
✅ AsyncBatch |
|
| Async transactions | ❌ | ✅ AsyncTransaction |
✅ AsyncTxn |
Developer Experience¶
| Feature | NDB | Firestore SDK | Cendry | Notes |
|---|---|---|---|---|
| IDE autocomplete | 🔶 Minimal | ❌ | ✅ Full | @dataclass_transform |
| Type checking (mypy/ty) | ❌ | 🔶 Partial stubs | ✅ Strict | Both mypy and ty pass |
| Context manager | ❌ | ✅ | ✅ | Auto-closes client |
| Subcollections | ❌ Different model | ✅ Nested refs | ✅ parent= parameter |
|
| Custom type handlers | ❌ | ❌ | ✅ register_type() |
Pluggable serialize/deserialize |
| Model hooks | ✅ _pre_put_hook, etc. |
❌ | ❌ | Lifecycle callbacks — not yet in Cendry |
What Cendry doesn't have (yet)¶
Features from NDB or the Firestore SDK that Cendry could add in future versions:
| Feature | Origin | Description | Effort |
|---|---|---|---|
| Computed properties | NDB | Read-only fields derived from other fields | Medium |
| Model hooks | NDB | _pre_save, _post_save, _pre_delete, _post_delete callbacks |
Medium |
| Property validators | NDB | field(validator=fn) for per-field validation |
Medium |
| ~~Projection queries~~ | ~~Both~~ | ~~Fetch only specific fields~~ | ✅ Done |
| Cursor export | Both | Expose pagination cursor tokens for stateless paging | Low |
allocate_ids() |
Both | Pre-allocate document IDs | Low |
| ~~Real-time listeners~~ | ~~SDK~~ | ~~on_snapshot() for live updates~~ |
✅ Done (sync) |
| Expando models | Both | Dynamic properties not in schema | Medium |
| ~~Distinct queries~~ | ~~Both~~ | ~~Not in Firestore Python SDK~~ | N/A |
Intentional differences
Some NDB features are intentionally not in Cendry:
Model.query()— Cendry uses the context as entry point, not the model classPickleProperty/GenericProperty— anti-pattern for document databases- Datastore mode — Cendry targets Firestore Native mode only