Track Dirty Fields¶
This guide covers how Gault tracks document state, detects modifications, and uses that information for efficient atomic saves.
How state tracking works¶
When you load or save a document through a manager, two things happen:
- Persistence tracking -- the instance is registered as persisted via
manager.persistence. - State snapshot -- a deep copy of the instance's attributes is stored via
manager.state_tracker.
These are automatic. You do not need to opt in.
person = await manager.get(Person, filter=Person.id == 1)
# Both are now true:
manager.persistence.is_persisted(person) # True
manager.state_tracker.get_dirty_fields(person) # set() -- empty, nothing changed yet
A newly constructed instance that has not been saved or loaded is not tracked:
new_person = Person(id=2, name="Alice", age=30)
manager.persistence.is_persisted(new_person) # False
Detecting dirty fields¶
After modifying an instance, call get_dirty_fields() to see which fields changed relative to the last snapshot:
person = await manager.get(Person, filter=Person.id == 1)
person.name = "Updated Name"
person.age = 50
dirty = manager.state_tracker.get_dirty_fields(person)
# dirty == {"name", "age"}
The returned set contains Python field names (not database aliases).
Atomic saves¶
Pass atomic=True to save() to update only the dirty fields. This is the primary consumer of dirty field tracking.
person = await manager.get(Person, filter=Person.id == 1)
person.age = 51
await manager.save(person, atomic=True)
What the generated update looks like¶
When atomic=True and the instance is already persisted, save() partitions fields into three categories:
| Category | MongoDB operator | Description |
|---|---|---|
| Primary key | filter | Used in the query filter to find the doc |
| Dirty fields | $set |
Updated on both insert and update |
| Unchanged fields | $setOnInsert |
Written only if the document is new |
For the example above, only age (mapped to its db_alias) goes into $set. All other non-PK fields go into $setOnInsert, so they are preserved if the document already exists.
When atomic is ignored¶
atomic=True has no effect if the instance is not persisted (i.e., it was never loaded from the database or previously saved). In that case, all fields are sent as $set, behaving like a regular upsert.
new_person = Person(id=3, name="Charlie", age=40)
# atomic=True has no effect here -- new_person is not persisted
await manager.save(new_person, atomic=True)
Non-atomic saves¶
Without atomic=True, every non-PK field is placed in $set regardless of whether it changed. This is simpler but may overwrite concurrent modifications to fields you did not touch.
Refreshing after save¶
Pass refresh=True to save() to update the in-memory instance with the document returned by MongoDB after the upsert:
await manager.save(person, refresh=True)
# person now reflects the exact state in the database,
# including any server-side defaults or modifications
After a refresh, the state snapshot is updated, so get_dirty_fields() returns an empty set again.
Manual refresh¶
You can refresh an instance independently of a save:
This re-reads the document using the primary key filter and raises NotFound if the document no longer exists.
Resetting local changes¶
The state tracker can revert an instance to its last-snapshotted state:
person.name = "Temporary change"
manager.state_tracker.reset(person)
# person.name is restored to its value at the time of the last snapshot
Shared tracking across operations¶
Persistence and StateTracker are per-manager singletons (created lazily). All operations through the same manager share the same tracker, so a document loaded via select() and later passed to save() is correctly recognized as persisted.
If you need isolated tracking (e.g., in tests), construct a manager with explicit instances: