Indexed Fields

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:

  • int
  • decimal.Decimal
  • datetime.date
  • datetime.datetime
  • datetime.time
  • None

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_at

This 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 = priority

After 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 = rank

The 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.