Indexed Fields
Indexed fields are the declarative indexing technique described in
Indexes. Use them when a field on a memo object is also a common
range-query or sort key, such as priority, due_at, created_at, or score.
With an imperative db0.index(), your code creates the index and
keeps it synchronized. With indexed fields, dbzero owns that bookkeeping: it
creates a managed index for each declared field and updates it when the field
changes.
import datetime as dt
import dbzero as db0
@db0.indexed_fields("priority", "due_at")
@db0.memo
class Task:
def __init__(self, title, priority, due_at=None):
self.title = title
self.priority = priority
self.due_at = due_at
Task("Fix billing bug", 1, dt.datetime(2026, 7, 17, 12, 0))
Task("Write release notes", 4)
Task("Triage support queue", 2, dt.datetime(2026, 7, 18, 9, 0))Querying an Indexed Field
The preferred way to retrieve an indexed-field index is to pass a field
reference from db0.fields_of(...) to
db0.index_of(...):
priority = db0.index_of(db0.fields_of(Task).priority)
urgent_tasks = db0.find(Task, priority.select(1, 2))
ordered_tasks = priority.sort(db0.find(Task))db0.fields_of(Task).priority identifies the field; it does not read a value
from a Task instance. This keeps normal Python class attributes and dataclass
defaults intact while giving dbzero APIs a stable field reference.
You can also look up the same index with the memo type and a field name:
priority = db0.index_of(Task, "priority")For dynamic field names, or names that cannot be written as Python attributes, use subscription:
field_name = "due_at"
due_at = db0.index_of(db0.fields_of(Task)[field_name])Indexed-field indexes are passive. Join index.select(...) with a positive
anchor such as db0.find(Task, ...); do not iterate index.select(...) by
itself.
Range queries use the same inclusive bounds as regular indexes:
due_at = db0.index_of(db0.fields_of(Task).due_at)
now = dt.datetime.now()
tomorrow = now + dt.timedelta(days=1)
due_soon = db0.find(Task, due_at.select(now, tomorrow))
unscheduled_first = due_at.sort(db0.find(Task), null_first=True)None is a real indexed key. A task whose due_at is explicitly set to None
is included in the due_at index and can be ordered with null_first.
What dbzero Synchronizes
The declared fields stay ordinary attributes. dbzero synchronizes the managed index during object construction, field assignment, field deletion, and object deletion:
task = Task("Review incident report", 3)
priority = db0.index_of(db0.fields_of(Task).priority)
task.priority = 1
assert list(db0.find(Task, priority.select(3, 3))) == []
assert list(db0.find(Task, priority.select(1, 1))) == [task]
del task.priority
assert list(db0.find(Task, priority.select(1, 1))) == []
task.priority = 5
assert list(db0.find(Task, priority.select(5, 5))) == [task]Each field update and its matching index update are applied atomically. If the new value cannot be used as an index key, dbzero raises an error and keeps the previous field value and index entry unchanged.
The managed index supports the query operations from regular indexes:
index.select(low=None, high=None, null_first=False)index.sort(query, desc=False, null_first=False)len(index)
Application code cannot mutate a managed index directly. add, remove,
clear, and flush raise an error because dbzero owns the index contents.
Supported Keys
Indexed fields use the same key rules as db0.index(). Supported
keys include:
intdecimal.Decimaldatetime.datedatetime.datetimedatetime.timeNone
An index stores one key family. For example, if the first non-None key is an
integer, assigning a datetime later raises an error. Strings are not supported
as index keys; use tags, tag fields, or a dictionary when a string is the lookup
value.
Indexed fields do not expand collections, calculate derived values, or observe in-place changes inside a stored value. The field value itself is the key.
Indexed Fields vs Manual Indexes
Use indexed fields when the key is exactly one field on the indexed object:
@db0.indexed_fields("created_at")
@db0.memo
class Event:
def __init__(self, created_at):
self.created_at = created_atThis avoids the common manual-index failure mode: changing a field but forgetting to remove the old key and add the new one.
Manual indexes still give more control. Use an imperative db0.index() when:
- the key is derived from multiple fields or external state
- one index should contain objects from unrelated memo types
- each instance needs a private index, such as
Client -> Orders - application code needs to add the same object under multiple keys
- the indexed value is not the object that owns the key field
@db0.memo
class Order:
def __init__(self, client, created_at):
self.client = client
self.created_at = created_at
@db0.memo
class Client:
def __init__(self, name):
self.name = name
self.orders_by_date = db0.index()
client = Client("Acme")
order = Order(client, dt.datetime.now())
client.orders_by_date.add(order.created_at, order)
# If the key changes, application code must update the index explicitly.
old_created_at = order.created_at
order.created_at = dt.datetime.now()
client.orders_by_date.remove(old_created_at, order)
client.orders_by_date.add(order.created_at, order)Declarative indexed fields reduce bookkeeping, but they intentionally do less.
If the index represents a custom relationship rather than one field on one memo
type, keep using db0.index().
Inheritance
An indexed field declared on a base memo class includes instances of the base class and its subclasses. A subclass can declare its own indexed fields, but it cannot redeclare the same inherited field as a second index.
@db0.indexed_fields("created_at")
@db0.memo
class Document:
def __init__(self, created_at):
self.created_at = created_at
@db0.indexed_fields("priority")
@db0.memo
class Ticket(Document):
def __init__(self, created_at, priority):
super().__init__(created_at)
self.priority = priority
created_at = db0.index_of(db0.fields_of(Ticket).created_at)
priority = db0.index_of(db0.fields_of(Ticket).priority)
recent_documents = db0.find(Document, created_at.select(start, end))
urgent_tickets = db0.find(Ticket, priority.select(1, 2))db0.index_of(db0.fields_of(Ticket).created_at) resolves to the index owned by
Document.
Migrating Indexed Fields
Changing an indexed-field declaration is a schema change. dbzero stores the active declaration with the memo type and migrates the managed indexes when the declaration changes.
Adding a field scans existing instances and populates the new index from their current field values:
@db0.memo
class Task:
def __init__(self, title, priority):
self.title = title
self.priority = priority
# Later:
@db0.indexed_fields("priority")
@db0.memo
class Task:
def __init__(self, title, priority):
self.title = title
self.priority = priorityAfter migration, existing tasks are queryable through the new managed index.
Objects where the field is absent do not contribute an entry. Objects where the
field is explicitly None contribute a None key.
Removing an indexed field declaration removes the managed index and stops future synchronization. Code that retained the old index wrapper should discard it.
Renaming a field with db0.rename_field(...)
preserves the indexed-field identity:
db0.rename_field(Task, "priority", "rank")
@db0.indexed_fields("rank")
@db0.memo
class Task:
def __init__(self, title, rank):
self.title = title
self.rank = rankThe existing managed index follows the renamed field instead of being rebuilt only because the Python name changed.
For large prefixes, treat declaration changes like any other migration. Disable automatic migration at startup and run it where your application performs schema changes:
db0.init("/var/lib/my-app/dbzero", no_auto_migrate=True)
db0.open("main", no_auto_migrate=True)
try:
Task("Write launch notes", 2)
except db0.MigrateError:
db0.migrate(Task)If migration fails, dbzero leaves the previous declaration and indexes active. Read-only processes do not apply declaration migrations; the prefix must be opened by a read-write owner and migrated there.