Skip to main content

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.

GORM 1.31
var users []User


err := db.WithContext(ctx).
	Preload("Posts").
	Where("age > ?", 18).
	Order("name").
	Limit(10).
	Find(&users).
	Error
Bun 1.3
var users []User


err := db.NewSelect().
	Model(&users).
	Relation("Posts").
	Where("age > ?", 18).
	Order("name").
	Limit(10).
	Scan(ctx)
ent 0.14
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.

ant
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​

MistakeantGORMBunentsqlc
Misspelled columnCompile error: your editor and go build flag itNot caught: it compiles and fails at runtimeNot caught: it compiles and fails at runtimeCompile error: your editor and go build flag itCaught by sqlc generate, before the build
"18" for an intCompile error: your editor and go build flag itNot caught: it compiles and fails at runtimeNot caught: it compiles and fails at runtimeCompile error: your editor and go build flag itCompile error: your editor and go build flag it
Misspelled relationCompile error: your editor and go build flag itNot caught: it compiles and fails at runtimeNot caught: it compiles and fails at runtimeCompile error: your editor and go build flag itCaught by sqlc generate, before the build
Column of another modelCaught by antvet. ant refuses it before sending the queryNot caught: it compiles and fails at runtimeNot caught: it compiles and fails at runtimeCompile error: your editor and go build flag itCaught by sqlc generate, before the build
No code generation stepNothing to generateNothing to generateNothing to generateNeeds go generate after a schema changeNeeds 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.

You writeant.Gt(&Users.Age, 18)
PostgreSQL getsWHERE "users"."age" > $1$1 = 18
&Users.Age becomes "users"."age"
A field pointer to User.Age names 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​

GORM
users, err := gorm.G[User](db).
	Where("agee <= ?", 18).
	Find(ctx)
Builds fine. Then, when it runs:
SELECT * FROM "users" WHERE agee <= 18
-- ERROR: column "agee" does not exist (SQLSTATE 42703)
Bun
err := db.NewSelect().Model(&users).
	Where("agee <= ?", 18).
	Scan(ctx)
Builds fine. Then, when it runs:
SELECT "user"."id", ... FROM "users" AS "user" WHERE (agee <= 18)
-- ERROR: column "agee" does not exist (SQLSTATE 42703)
ent
users, err := client.User.Query().
	Where(user.AgeLTE(18)).
	All(ctx)

Checked, once go generate has built AgeLTE from the schema.

ant
users, err := db.Users.
	Where(ant.Lte(&Users.Agee, 18)).
	All(ctx)
go build
Users.Agee undefined (type *User has no field or method Agee)
Spelled right, it sends
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"
  • age36
  • activetrue

ant SaveChanges

The database now holds

  • name"Ada"
  • age0
  • activefalse
GORM
db.Model(&user).Updates(User{
	Name:   "Ada",
	Age:    0,
	Active: false,
})
Sends
UPDATE "users" SET "updated_at"='…',"name"='Ada' WHERE "id" = 1
-- no age, no active

Select("Age", "Active") or a map writes them.

Bun
_, err := db.NewUpdate().Model(&user).
	OmitZero().
	WherePK().
	Exec(ctx)
Sends
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.

xorm
engine.ID(id).Update(&User{
	Name: "Ada",
	Age:  0,
})

Cols or AllCols writes them.

ant
user, err := db.Users.Find(ctx, id)


user.Age = 0
user.Active = false


err = db.SaveChanges(ctx)
Sends
UPDATE "users" SET "age" = $1, "active" = $2, "updated_at" = $3
WHERE "users"."id" = $4
  • ant compares against what it loaded, so 0, false, "" and nil are changes.
  • Zero has a special meaning in only two places: a zero integer key is generated by the database, and a zero field tagged default is left for the database to fill in.

One query per row​

Include loads a relation for all rows in one query.

A page showsusers with their posts
One query per row4 queries
  1. SELECT * FROM users
  2. SELECT * FROM posts WHERE user_id = 1
  3. SELECT * FROM posts WHERE user_id = 2
  4. SELECT * FROM posts WHERE user_id = 3
All at once2 queries
  1. SELECT * FROM users
  2. SELECT * FROM posts WHERE user_id IN (1, 2, 3)
pop
err := tx.Eager("Books").All(&users)
Runs, as pop's docs describe it
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.

GORM
err := db.Preload("Posts").Find(&users).Error
Runs
SELECT * FROM "users"
SELECT * FROM "posts" WHERE "posts"."user_id" IN (1,2,3)
Bun
err := db.NewSelect().Model(&users).
	Relation("Posts").
	Scan(ctx)
Runs
SELECT "user"."id", ... FROM "users" AS "user"
SELECT "post"."id", ... FROM "posts" AS "post" WHERE ("post"."user_id" IN (1, 2, 3))
ant
users, err := db.Users.
	Include(&Users.Posts).
	All(ctx)
Runs
SELECT "users"."id", ... FROM "users"
SELECT "posts"."id", ... FROM "posts" WHERE "posts"."user_id" IN ($1, $2, $3)
  • &Users.Posts is a typed field pointer, so Include(&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.

Request A
Loads the userAda, plan free
Fixes the name, savesAda L.
Request B
Upgrades the planplan pro
Saving the whole rowname = "Ada L.", plan = "free"The upgrade is gone. No error was reported.
Saving what changedname = "Ada L.", plan = "pro"Both changes survive.
GORM
user.Name = "Ada L."


db.Save(&user)
Sends
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.

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

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

ant
user, err := db.Users.Find(ctx, id)


user.Name = "Ada L."


err = db.SaveChanges(ctx)
Sends
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​