SaveChanges
Change what you loaded, then call SaveChanges. ant compares each entity with a snapshot it took when loading it, and writes one minimal UPDATE per changed entity, all in one transaction.
The basics
user, err := db.Users.Find(ctx, id)
if err != nil {
return err
}
user.Age++
err = db.SaveChanges(ctx)
UPDATE "users" SET "age" = $1, "updated_at" = $2 WHERE "users"."id" = $3
Only the changed column goes out, so a value someone else changed in the meantime isn't overwritten with your stale copy. If nothing changed, no statement is sent at all.
Zero values are real
The comparison is against what was loaded, so 0, "", false, and nil are changes like any other.
user, err := db.Users.Find(ctx, id)
user.Age = 0
user.Bio = nil
err = db.SaveChanges(ctx)
A write through a pointer is seen too, and so is a change inside an ant.JSON value.
What is tracked
All, First, Single, and Find track their results, and so do Insert and the rows an Include loads. All returns pointers, so changes in a range count.
users, err := db.Users.All(ctx)
for _, user := range users {
user.Age = 50
}
err = db.SaveChanges(ctx)
These aren't tracked, so changing them saves nothing:
| Results of | Why |
|---|---|
Each | It yields values, one at a time |
Column, Omit | Their zero fields aren't real values |
NoTracking | You asked |
ant.Raw, FromRaw, Project, Select | They aren't whole entities |
Several entities
SaveChanges covers everything the context tracks, in one transaction, parents before children on insert and children first on delete.
user.Age = 30
post.Title = "Edited"
err := db.SaveChanges(ctx) // two UPDATEs, one transaction
Is anything pending?
user.Age = 60
db.HasChanges() // true
One instance per row
A context holds one instance per row, so a later query returns the same pointer, with your unsaved changes.
first, err := db.Users.Find(ctx, id)
first.Age = 70
again, err := db.Users.Where(ant.Eq(&U.ID, id)).Single(ctx)
fmt.Println(first == again, again.Age) // true 70
A bulk Update doesn't touch tracked entities. Reload picks up database changes, and they become the new baseline.
Turning it off
NoTracking opts one query out, in any position in the chain. WithTracking(false) turns tracking off for a whole context.
db, err := ant.Open[AppDB](ctx, postgres.Dialect, dsn, ant.WithTracking(false))
session := ant.Session(db, ant.WithTracking(true))
That second line is a nice pattern: a shared context that never grows, and tracking only in the sessions that actually save.
Custom comparers
Deep equality decides what changed. b.Comparer overrides it, for values where that is wrong, such as a list whose order means nothing.
type Tagged struct {
ID int64
Items ant.JSON[[]string]
}
func (Tagged) Configure(b *ant.ModelBuilder[Tagged]) {
b.Comparer(&TG.Items, sameItems)
}
func sameItems(a, b ant.JSON[[]string]) bool {
return slices.Equal(slices.Sorted(slices.Values(a.V)), slices.Sorted(slices.Values(b.V)))
}
var TG = ant.Fields[Tagged]()
A tracker keeps growing until it's cleared, and a shared one mixes up concurrent requests. Use a session per unit of work.
See it tested: tests/features/change_tracking/save_changes_test.go