Indexed Fields¶
Indexed fields provide secondary indexing for non-key fields, enabling efficient exact-match queries without making the field part of the model's Redis key (identity).
The Problem¶
In Popoto, KeyField conflates two concerns:
- Identity -- the field's value forms part of the Redis storage key
- Indexing -- the field is queryable via
filter()
This means that to query on a field like email or status, you must make it a
KeyField, which changes the model's Redis key structure. If you later rename an
email, the entire Redis key changes, potentially orphaning references.
The Solution¶
IndexedField and UniqueField decouple querying from identity. They maintain
Redis Set indexes (identical to KeyField's indexing mechanism) but do not participate
in the Redis key.
from popoto import Model, AutoKeyField, IndexedField, UniqueField, Field
class User(Model):
user_id = AutoKeyField()
email = UniqueField(type=str) # indexed + unique, NOT part of key
status = IndexedField(type=str) # indexed, NOT part of key
name = Field(type=str) # not indexed, not part of key
The Redis key for this model is based solely on user_id (e.g.,
User:a1b2c3d4...). The email and status fields are indexed separately,
enabling queries like:
User.query.filter(email="alice@example.com")
User.query.filter(status="active")
User.query.filter(status__in=["active", "pending"])
Field(indexed=True)¶
You can also enable indexing on a plain Field by passing indexed=True:
class Product(Model):
sku = AutoKeyField()
category = Field(type=str, indexed=True) # indexed, not unique
barcode = Field(type=str, indexed=True, unique=True) # indexed + unique
This is functionally identical to using IndexedField and UniqueField shortcuts.
IndexedField¶
IndexedField is a shortcut for Field(indexed=True). It creates a non-key field
with Set-based secondary indexing.
from popoto import Model, AutoKeyField, IndexedField
class Order(Model):
order_id = AutoKeyField()
status = IndexedField(type=str)
region = IndexedField(type=str, null=True)
Supported Query Lookups¶
IndexedField supports the same lookups as KeyField:
| Lookup | Description | Redis Operation |
|---|---|---|
status= |
Exact match | SMEMBERS |
status__in= |
Match any value in list | SUNION |
status__isnull= |
Null / non-null check | SMEMBERS / SCAN |
status__startswith= |
Prefix match | SCAN + SMEMBERS |
status__endswith= |
Suffix match | SCAN + SMEMBERS |
# Exact match
Order.query.filter(status="shipped")
# IN query -- efficient server-side SUNION
Order.query.filter(status__in=["pending", "processing"])
# Null check
Order.query.filter(region__isnull=True)
# Pattern matching (uses SCAN, slower on large datasets)
Order.query.filter(region__startswith="US-")
Combining with Other Filters¶
Indexed field filters compose with all other filter types using AND logic:
from popoto import SortedField
class Product(Model):
product_id = AutoKeyField()
category = IndexedField(type=str)
price = SortedField(type=float)
# Combine indexed field filter with sorted field range query
affordable_electronics = Product.query.filter(
category="electronics",
price__lte=50.0,
)
UniqueField¶
UniqueField is a shortcut for Field(indexed=True, unique=True). It adds a
per-value uniqueness constraint on top of the secondary index.
from popoto import Model, AutoKeyField, UniqueField, Field
class User(Model):
user_id = AutoKeyField()
email = UniqueField(type=str)
name = Field(type=str)
user1 = User.create(email="alice@example.com", name="Alice")
# Attempting a duplicate email raises ModelException
try:
user2 = User.create(email="alice@example.com", name="Not Alice")
except Exception as e:
print(e)
# => Uniqueness violation on User.email: value 'alice@example.com' is already taken
Constraints¶
UniqueFieldcannot be null (null=Falseis enforced)- Setting
unique=FalseraisesModelException - Setting
null=TrueraisesModelException
Concurrency Guarantee¶
UniqueField enforces uniqueness inside an atomic server-side Lua script
(INDEX_SWAP_LUA). Under concurrent cross-process writes, the script runs as a
single Redis command: it reads the current index pointer, performs the uniqueness
check, and moves the record to the new Set — all without a race window.
When a caller provides an external Redis pipeline, a best-effort SMEMBERS pre-check
is made before queuing the EVAL. The authoritative guarantee is still enforced at
pipeline.execute() time when the Lua script runs on the server; the pre-check only
surfaces conflicts earlier for the common case.
How It Works¶
Index Key Pattern¶
Indexed fields maintain Redis Sets following this pattern:
$IndexF:ModelName:field_name:value -> Set of redis_keys
$UniquF:ModelName:field_name:value -> Set of redis_keys (UniqueField)
For example, a User model with status = IndexedField(type=str):
$IndexF:User:status:active -> {User:abc123, User:def456}
$IndexF:User:status:inactive -> {User:ghi789}
This mirrors the $KeyF pattern used by KeyField.
A datetime value in an index key follows the same canonical form as a datetime
KeyField: UTC-normalized and fixed-width, $IndexF:Event:at:2026-08-07T05:00:00.123456Z.
See Datetime KeyFields.
Save Behavior¶
When a model instance is saved, an atomic server-side Lua script (INDEX_SWAP_LUA)
handles the index update in a single Redis command:
- Reads a server-authoritative pointer to identify which Set the record currently belongs to
- If
unique=True, scans the target Set for any member other than the current record and raisesModelExceptionimmediately on conflict - Atomically removes the record from the old Set, adds it to the new Set, updates the pointer, and writes the field bytes — no race window between any of these steps
This eliminates the stale-snapshot SREM+SADD race that existed in earlier versions.
For Model.save() calls that don't pass an external pipeline (the common case,
including Model.create()), the uniqueness EVAL for IndexedField/UniqueField
values runs before any other write for that record is issued. A genuine uniqueness
conflict therefore raises ModelException without leaving any trace — no orphaned
hash, no class-set membership, nothing to clean up.
Operator Note¶
The server-authoritative pointer is stored in a standalone Redis key (a plain
string, GET/SET), not as a field inside the model hash. redis-cli HGETALL
<Model:key> shows only your declared fields — no extra bookkeeping field. The
pointer key itself is namespaced under $IdxPtr:, alongside Popoto's other
internal keys ($Class:, $KeyF:, $SortedF:), and follows the pattern:
A model name can never begin with $, so this key sits outside every model's
own key space and can never collide with a model key glob (e.g. the
AutoKeyField lookup path, which resolves via a scan_keys() glob).
- Do NOT edit or delete it manually. Removing it reopens the index-stranding race until the record's next save.
- It is invisible to the Python model API:
filter(), attribute access, andis_valid()all ignore it (it isn't part of the model hash at all). - The
$IndexF:and$UniquF:Set schema itself is unchanged from earlier versions.
Versions prior to this fix (1.8.0-1.8.2, see issues #476, #540)
An earlier 1.8.0 revision stored this pointer as a {field_name}\x00idxset
field inside the model hash. That is forward-incompatible: any decoder
that unconditionally msgpack.unpackb()s every hash field (all pre-1.8.0
releases) crashes with msgpack.exceptions.ExtraData on such records.
The 1.8.1/1.8.2 fix for that moved the pointer to a side key, but derived
it by suffixing the model hash key itself
(<Model:key>\x00idxptr\x00<field_name>) rather than namespacing it under
$IdxPtr:. A NUL byte does not keep a key out of a glob — Redis glob *
matches any byte, including NUL — so that key was matched by the same
glob AutoKeyField lookups scan, and the follow-up HGETALL failed with
WRONGTYPE. This regressed get_by_id()-style AutoKey lookups to return
nothing for rows that exist (#540).
Current code never writes to either legacy location. Records written by
an affected 1.8.0-1.8.2 release are read as a migration fallback and
self-heal (the legacy pointer is scrubbed and moves to the $IdxPtr:
side key) the next time they're saved. During a rolling upgrade, a
legacy pointer still visible to a not-yet-upgraded node is skipped by a
type guard on the scan path rather than crashing the read. No offline
migration step is required; a full clean cut-over is optional:
Delete Behavior¶
When a model instance is deleted, the field hooks that clean up secondary indexes (including reading the pointer above) run before the model hash itself is physically removed, so the correct current Set is always found — the instance's Redis key is then removed from that index Set, and the pointer key is cleaned up.
Comparison Table¶
| Feature | Field | IndexedField | UniqueField | KeyField | UniqueKeyField |
|---|---|---|---|---|---|
| Part of Redis key | No | No | No | Yes | Yes |
| Queryable via filter() | No | Yes | Yes | Yes | Yes |
| Exact match | No | Yes | Yes | Yes | Yes |
__in lookup |
No | Yes | Yes | Yes | Yes |
__startswith / __endswith |
No | Yes | Yes | Yes | Yes |
__isnull lookup |
No | Yes | Yes | Yes | Yes |
__contains lookup |
No | No | No | Yes | No |
| Uniqueness enforced | No | No | Yes | No | Yes |
| Can be null | Yes | Yes | No | Yes | No |
Immutable Keys and Key Migration¶
KeyField values are immutable by default after the initial save. Changing a KeyField
value and calling save() raises KeyMutationError:
from popoto import KeyMutationError
instance = MyModel.query.get(name="old_name")
instance.name = "new_name"
try:
instance.save()
except KeyMutationError as e:
print(e)
# => KeyField 'name' changed from 'old_name' to 'new_name'. Use save(migrate_key=True).
To intentionally migrate a key, pass migrate_key=True:
The migrate_key=True flag handles the full migration: deleting the old Redis hash,
removing old index entries (sorted sets, geo sets, unique constraints, indexed fields),
and creating the new key with all indexes pointing to it.
Warning
Key migration changes the Redis key (identity) of the instance. Any external references to the old key will break. Use this intentionally, not accidentally.