Why ant
Four problems Go ORMs commonly have, and how ant, GORM, Bun, ent and sqlc each handle them. Code for the other libraries comes from their docs; the GORM and Bun SQL was captured from real runs.
One query, four libraries
Users over 18 with their posts, sorted by name, 10 at a time. The red strings are not checked. A typo in one is a runtime error. A test runs all four and checks they return the same rows.
var users []User
err := db.WithContext(ctx).
Preload("Posts").
Where("age > ?", 18).
Order("name").
Limit(10).
Find(&users).
Error
var users []User
err := db.NewSelect().
Model(&users).
Relation("Posts").
Where("age > ?", 18).
Order("name").
Limit(10).
Scan(ctx)
users, err := client.User.Query().
WithPosts().
Where(user.AgeGT(18)).
Order(user.ByName()).
Limit(10).
All(ctx)
Checked. go generate builds these methods from ent's schema.
users, err := db.Users.
Include(&Users.Posts).
Where(ant.Gt(&Users.Age, 18)).
OrderBy(&Users.Name).
Limit(10).
All(ctx)
Users is the probe for your own User struct. Nothing is generated.
What the compiler catches
| Mistake | ant | GORM | Bun | ent | sqlc |
|---|---|---|---|---|---|
| Misspelled column | Compile error: your editor and go build flag it | Not caught: it compiles and fails at runtime | Not caught: it compiles and fails at runtime | Compile error: your editor and go build flag it | Caught by sqlc generate, before the build |
"18" for an int | Compile error: your editor and go build flag it | Not caught: it compiles and fails at runtime | Not caught: it compiles and fails at runtime | Compile error: your editor and go build flag it | Compile error: your editor and go build flag it |
| Misspelled relation | Compile error: your editor and go build flag it | Not caught: it compiles and fails at runtime | Not caught: it compiles and fails at runtime | Compile error: your editor and go build flag it | Caught by sqlc generate, before the build |
| Column of another model | Caught by antvet. ant refuses it before sending the query | Not caught: it compiles and fails at runtime | Not caught: it compiles and fails at runtime | Compile error: your editor and go build flag it | Caught by sqlc generate, before the build |
| No code generation step | Nothing to generate | Nothing to generate | Nothing to generate | Needs go generate after a schema change | Needs sqlc generate after a query or schema change |
Compile error, shown in your editor Caught by a separate step: sqlc generate, or antvet for ant Compiles and fails at runtime Needs go generate or sqlc generate
Each cell was tested by compiling the mistake, or for sqlc by running sqlc generate 1.30. GORM's optional CLI generates typed field helpers.
How &Users.Age becomes a column
&Users.Age is a *int, so the compiler checks the field name, and checks that the value compared with it is an int.
ant.Gt(&Users.Age, 18)WHERE "users"."age" > $1$1 = 18- &Users.Age becomes "users"."age"
- A field pointer to
User.Agenames the column. Renaming the field renames it in every query. - 18 becomes $1
- It's sent as a parameter, never pasted into the SQL.
Users is declared once, next to the model: var Users = ant.Fields[User]()
How ant finds the column
ant.Fields[User]() allocates one empty User per model type and records its address. &Users.Age is an address inside that value, so ant subtracts the start address to get the field's offset, then matches the offset and the pointer's type against the fields it read from User by reflection. It checks the type too, because a struct and its first field share an address. Resolving a field pointer takes about 20 ns and allocates nothing.
This relies on the Go runtime not moving heap objects, which it does not do today. A test in ant, TestProbeSurvivesGC, guards it.
internal/meta/meta.go· Resolve, simplified
func Resolve[V any](ptr *V) (Ref, bool) {
addr := uintptr(unsafe.Pointer(ptr))
t := reflect.TypeFor[V]()
for _, p := range probes {
if addr < p.base || addr >= p.base+p.model.Type.Size() {
continue
}
off := addr - p.base
for _, f := range p.model.Fields {
if f.Offset == off && f.Type == t {
return Ref{Model: p.model, Field: f}, true
}
}
}
return Ref{}, false
}
What it fixes
Each of these has a workaround in the other libraries. ant's defaults avoid them.
Queries are strings
users, err := gorm.G[User](db).
Where("agee <= ?", 18).
Find(ctx)
SELECT * FROM "users" WHERE agee <= 18
-- ERROR: column "agee" does not exist (SQLSTATE 42703)
err := db.NewSelect().Model(&users).
Where("agee <= ?", 18).
Scan(ctx)
SELECT "user"."id", ... FROM "users" AS "user" WHERE (agee <= 18)
-- ERROR: column "agee" does not exist (SQLSTATE 42703)
users, err := client.User.Query().
Where(user.AgeLTE(18)).
All(ctx)
Checked, once go generate has built AgeLTE from the schema.
users, err := db.Users.
Where(ant.Lte(&Users.Agee, 18)).
All(ctx)
Users.Agee undefined (type *User has no field or method Agee)
SELECT "users"."id", ... FROM "users" WHERE "users"."age" <= $1
Zero values disappear
GORM's Updates, Bun's OmitZero and xorm's Update skip zero-valued fields.
Loaded as Ada, 36, active. Try your own values.
You set age to 0 and active to false. GORM wrote neither, and returned no error.
GORM Updates(user)
The database now holds
name"Ada"age36activetrue
ant SaveChanges
The database now holds
name"Ada"age0activefalse
db.Model(&user).Updates(User{
Name: "Ada",
Age: 0,
Active: false,
})
UPDATE "users" SET "updated_at"='…',"name"='Ada' WHERE "id" = 1
-- no age, no active
Select("Age", "Active") or a map writes them.
_, err := db.NewUpdate().Model(&user).
OmitZero().
WherePK().
Exec(ctx)
UPDATE "users" AS "user" SET "name" = 'Ada' WHERE ("user"."id" = 1)
-- no age, no active
OmitZero is opt-in. Without it, Bun writes every column.
engine.ID(id).Update(&User{
Name: "Ada",
Age: 0,
})
Cols or AllCols writes them.
user, err := db.Users.Find(ctx, id)
user.Age = 0
user.Active = false
err = db.SaveChanges(ctx)
UPDATE "users" SET "age" = $1, "active" = $2, "updated_at" = $3
WHERE "users"."id" = $4
- ant compares against what it loaded, so
0,false,""andnilare changes. - Zero has a special meaning in only two places: a zero integer key is generated by the database, and a zero field tagged
defaultis left for the database to fill in.
One query per row
Include loads a relation for all rows in one query.
SELECT * FROM usersSELECT * FROM posts WHERE user_id = 1SELECT * FROM posts WHERE user_id = 2SELECT * FROM posts WHERE user_id = 3
SELECT * FROM usersSELECT * FROM posts WHERE user_id IN (1, 2, 3)
err := tx.Eager("Books").All(&users)
SELECT * FROM users;
SELECT * FROM books WHERE user_id=1
SELECT * FROM books WHERE user_id=2
SELECT * FROM books WHERE user_id=3
EagerPreload batches them.
err := db.Preload("Posts").Find(&users).Error
SELECT * FROM "users"
SELECT * FROM "posts" WHERE "posts"."user_id" IN (1,2,3)
err := db.NewSelect().Model(&users).
Relation("Posts").
Scan(ctx)
SELECT "user"."id", ... FROM "users" AS "user"
SELECT "post"."id", ... FROM "posts" AS "post" WHERE ("post"."user_id" IN (1, 2, 3))
users, err := db.Users.
Include(&Users.Posts).
All(ctx)
SELECT "users"."id", ... FROM "users"
SELECT "posts"."id", ... FROM "posts" WHERE "posts"."user_id" IN ($1, $2, $3)
&Users.Postsis a typed field pointer, soInclude(&Users.Name)won't build.- Reading a field never runs a query. A relation that wasn't loaded is
nil. - For a loop written by hand, diagnostics warns when one statement repeats within a request.
Saving undoes someone else's change
When a save writes the whole row back, the last of two requests undoes the other's change.
name = "Ada L.", plan = "free"The upgrade is gone. No error was reported.name = "Ada L.", plan = "pro"Both changes survive.user.Name = "Ada L."
db.Save(&user)
UPDATE "users" SET "created_at"='…',"updated_at"='…',"name"='Ada L.',
"plan"='free',"age"=36,"active"=true
WHERE "id" = 1
Bun's Update on a model also writes every field.
u, err = u.Update().
SetName("Ada L.").
Save(ctx)
Only the fields in the setter chain are written. The code that saves has to know which fields changed.
UPDATE users SET
name = coalesce(sqlc.narg('name'), name),
plan = coalesce(sqlc.narg('plan'), plan)
WHERE id = sqlc.arg('id');
Safe, with a statement for each shape of update or a coalesce for every optional column.
user, err := db.Users.Find(ctx, id)
user.Name = "Ada L."
err = db.SaveChanges(ctx)
UPDATE "users" SET "name" = $1, "updated_at" = $2
WHERE "users"."id" = $3
- Code can change fields anywhere, and nothing has to pass along which ones changed.
- If both requests change the same field, the last one wins. With an
ant:"version"field, the second save fails instead.
Migrations
ant never changes your schema on its own. There is no auto-migrate, and Open doesn't touch the schema. migrate diff compares your structs with a stored snapshot and writes the difference as a plain SQL file, which you review before migrate up applies it. No database is needed to generate it. For an existing database, migrate drift lists every difference between your structs and the live schema until there are none.
Migrations run on PostgreSQL only. On the other databases ant reads and writes your existing tables, and your current migration tool keeps the schema. Migrations covers new projects and how ant compares with GORM's AutoMigrate, Atlas and sqlc. Adopting a database covers existing ones.
What it costs
ant keeps a copy of every row it loads, so SaveChanges can find what changed. On a 1000-row read, that makes ant 1.7 times slower than the same query with NoTracking, and uses 3 times the memory. Use NoTracking for lists, exports and reports; with it, ant read 1000 rows faster than GORM, Bun and ent in our runs.
Single-row reads and writes cost about the same in every library, because the round trip to the database is most of the time. ant allocates more than Bun and plain SQL on small queries.
Benchmarks has the full tables and how to run them.
Limits
- Some mistakes compile. A field pointer from another model's probe, a pointer that isn't from a probe, and a
Findkey of the wrong type are refused when the query runs, not at build time. antvet reports them ingo vet. ent and sqlc catch these at build or generate time. - Go 1.27 or later. ant uses generic methods, so it can't support older Go.
- Pre-1.0. The API can still change between releases.
- Migrations are PostgreSQL only. The other four databases get queries, writes, relations, tracking and transactions, and keep their current migration tool.
- No down migrations. To undo a change, write a new migration.
- Tracking holds memory. A tracker grows until it's discarded and serves one unit of work at a time, so use an
ant.Sessionper request. - The last write wins when two requests change the same field. Add an
ant:"version"field to make the second save fail instead. - Probes rely on heap objects not moving, which Go does not do today.