Skip to main content

v0.1.0 · pre-1.0, so the API can still change

Database code the compiler can read.

ant is a Go ORM built on plain structs. Queries point at your fields instead of naming columns in strings, so the compiler reads them too: rename a field and every query follows, get a type wrong and it won't build. And there's nothing to generate.

go get github.com/skudra-go/ant

No strings

Columns are field pointers. Misspell one, or pass the wrong type, and it won't build.

No code generation

Just generic Go. The structs you already wrote are the models.

No hidden queries

You load what you ask for, in batches. Anything you didn't load stays empty.

Plain SQL migrations

Written from your structs, read by you, and runnable by any tool.

A taste​

main.go
type User struct {
ant.Base // ID, CreatedAt, UpdatedAt
Name string
Age int
Posts []Post // 1:N, found through Post.UserID
}

type Post struct {
ant.Base
UserID int64
Title string
}

type AppDB struct {
ant.Context
Users ant.Table[User]
Posts ant.Table[Post]
}

var U = ant.Fields[User]()

db, err := ant.Open[AppDB](ctx, postgres.Dialect, dsn)

adults, err := db.Users.
Where(ant.Gt(&U.Age, 18)). // ant.Gt(&U.Age, "18") won't compile
Include(&U.Posts). // one batched query, not one per user
All(ctx)

adults[0].Age++

err = db.SaveChanges(ctx) // UPDATE only what changed

Everything in the lit part is checked by the compiler: the field, the type of the value, the relation. The rest is plain Go.

How &U.Age becomes a column​

Most Go ORMs name a column with a string, and the compiler can't see inside a string. ant uses a pointer to your struct field instead, and the compiler can check that.

You writeant.Gt(&U.Age, 18)
PostgreSQL getsWHERE "users"."age" > $1$1 = 18
&U.Age becomes "users"."age"
It points at the Age field of your User struct, so ant knows the column. Rename the field and every query follows.
18 becomes $1
It has to be an int, because Age is one. Write "18" and it won't build. It's sent as a parameter, never pasted into the SQL.

U is declared once, next to the model: var U = ant.Fields[User]()

What it fixes​

Go has some genuinely good database libraries, and each problem below has a workaround somewhere. These are the gaps ant closes by default. The other libraries' code comes from their own docs, and the GORM and Bun SQL is what v1.31 and v1.3 actually sent when this was run.

Queries are strings​

When a column is a string, a typo still compiles. You find out when the query runs, and only if something runs it.

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)

The typo goes straight into the SQL, and you hear about it from the database.

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)

Same story. It reads nicely, like SQL, but to the compiler it's just a string.

ent
users, err := client.User.Query().
	Where(user.AgeLTE(18)).
	All(ctx)

This one is checked: misspell AgeLTE and it won't build. The catch is that it's generated, from a schema you write in ent's own DSL, and it only exists after go generate.

ant
users, err := db.Users.
	Where(ant.Lte(&U.Agee, 18)).
	All(ctx)
go build
U.Agee undefined (type *User has no field or method Agee)
Spelled right, it sends
SELECT "users"."id", ... FROM "users" WHERE "users"."age" <= $1
  • The typo never gets as far as the database. Your build stops it.
  • &U.Age is a *int, so the value has to be an int too.
  • There's nothing to generate. The struct you already have is the schema.

Zero values disappear​

Go can't tell a field you set to 0 from one you never touched, so some libraries treat zero as "not set". The update compiles, looks right, and quietly skips your change.

Loaded as Ada, 36, active. Try your own values.

You set age to 0. GORM left it at 36, and returned no error.

GORM Updates(user)

The database now holds

  • name"Ada"
  • age36
  • activetrue

ant SaveChanges

The database now holds

  • name"Ada"
  • age0
  • activetrue
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

Only non-zero fields go out. You can fix it with Select("Age", "Active") or a map, as long as you remember to every time.

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

At least you opt in. Once you do, though, a real 0 can't be written either. Leave it off and Bun writes every column.

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

The same rule, unless you name the columns with Cols or AllCols.

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 count as changes like anything else.
  • Nothing is guessed from the value, so there's nothing to remember.
  • Zero only means something where you ask for it: a zero integer key means "generate one", and a field tagged default leaves zero for the database to fill in.

One query per row​

A page loads a list, then loads something for each row. That's one query for the list, plus one per row: the N+1 problem. With 3 rows in development nobody notices. With 500 in production, the page crawls.

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

That's the default: one query per user. EagerPreload batches them, if you know to reach for it.

GORM
err := db.Preload("Posts").Find(&users).Error
Runs
SELECT * FROM "users"
SELECT * FROM "posts" WHERE "posts"."user_id" IN (1,2,3)

Batched, which is exactly right. The relation is a string, though, so Preload("Psots") builds fine and fails when it runs.

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))

Batched too, with the same string.

ant
users, err := db.Users.
	Include(&U.Posts).
	All(ctx)
Runs
SELECT "users"."id", ... FROM "users"
SELECT "posts"."id", ... FROM "posts" WHERE "posts"."user_id" IN ($1, $2, $3)
  • Always batched: one query for the users, one for all of their posts.
  • The relation is a typed pointer, so Include(&U.Name) won't build.
  • Reading a field never runs a query. A relation you didn't load is just nil.
  • If you write the loop by hand anyway, diagnostics will warn you when one statement keeps repeating in a request.

Saving undoes someone else's change​

Two requests load the same user. If saving writes the whole row back, whichever finishes last quietly undoes the other one'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, and nothing reported an error.
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

Save writes every field, including the plan it loaded before the upgrade. Bun's Update on a model does the same.

ent
u, err = u.Update().
	SetName("Ada L.").
	Save(ctx)

Safe: setters chain, and only what you name gets written. The cost is that the code doing the saving has to know what changed, so logic spread across functions has to pass that along.

sqlc
UPDATE users SET
	name = coalesce(sqlc.narg('name'), name),
	plan = coalesce(sqlc.narg('plan'), plan)
WHERE id = sqlc.arg('id');

Also safe, and also explicit: you write 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
  • Only name is written, so the plan stays pro.
  • Your code changes fields wherever it likes, and nothing has to keep a list of what changed.
  • If both requests change the same field, the last one still wins. Add an ant:"version" field and the second save fails instead.

Your database, new or existing​

An ORM has to work with the database you actually have. ant never changes your schema behind your back: there's no auto-migrate, Open doesn't touch anything, and every change is a plain SQL file you get to read first.

Starting a new project

  1. Write your structsThey're the schema.
  2. migrate diff create_tablesWrites the first SQL file. No database needed.
  3. Read the fileIt's plain SQL, and yours to edit.
  4. migrate upApplies it, and records that it ran.

Bringing an existing database

  1. pg_dump --schema-onlyYour schema, as it is today.
  2. Write structs to matchBy hand, or with an AI assistant and this prompt.
  3. migrate driftLists every difference. Fix the structs until there are none left.
  4. migrate adoptYour dump becomes migration 0001. baseline marks it as already applied.
From here, both are the same. Change a struct, run diff, read the SQL, run up. check fails your CI if someone forgot.

drift is what makes adopting safe. It compares your structs with the live database and lists every difference, so you know the models are right before anything depends on them.

go run ./cmd/migrate drift
The database differs from the models. To match, it would need to:
change column users.name: type character varying(50) to text
add unique users_email_key on users

Read each line as something to fix in a struct, not in the database. Keep going until it says No drift.

What that means for your project​

  • Adopting doesn't touch your tables. baseline records your schema as already applied. The only thing it adds is its own table, ant_migrations. Your data, and whatever runs your app today, carry on as before.
  • You can keep your migration tool. diff and check don't need a database, so ant can write the SQL while golang-migrate, or whatever you use now, keeps running it.
  • You can start with just queries. Structs that match your tables are all you need to read and write. Querying doesn't depend on migrations at all.
  • You can leave. The files are ordinary SQL in a common layout, and the history table is yours to drop.
PostgreSQL only, for now

Migrations run on PostgreSQL. On MySQL, MariaDB, SQLite, and SQL Server, ant reads and writes your existing tables, and your schema stays with whatever tool you use now.

Next to the alternatives​

GORM
db.AutoMigrate(&User{})

It changes the live database directly, with no file to review, and in its own words "WON'T delete unused columns". For versioned migrations, its docs point you to a separate tool, Atlas.

ent, with Atlas
atlas migrate diff add_bio \
--dir "file://ent/migrate/migrations" \
--to "ent://ent/schema" \
--dev-url "docker://mysql/8/ent"

Versioned SQL files you can review, from a solid tool. It needs ent's schema DSL, though, and a dev database to replay your history on.

sqlc
sqlc does not perform database migrations for you.

That's straight from its docs. It reads the files of whichever migration tool you pick.

go run ./cmd/migrate diff add_bio
Wrote migrations/versions/0002_add_bio.up.sql
add column users.bio
Review it, then apply it with: up
0002_add_bio.up.sql
ALTER TABLE "users" ADD COLUMN "bio" text;
  • It compares your structs with a stored snapshot, so generating doesn't need a database.
  • Risky statements come with a warning above them, and renames don't lose data.
  • It's plain SQL, committed with your code. There are no down migrations: to undo something, you write a new one.

The migrations guide covers the rest, and adopting a database walks the existing-project path step by step.

Where it stands​

ant hasn't reached 1.0, so the API can still change between releases. It needs Go 1.27 or later, because it leans on generic methods.

go get github.com/skudra-go/ant

The reference on pkg.go.dev has every exported name with its doc comment. These pages link to it too: the first time a page mentions a name like ant.Open, it's a link, and hovering it shows the declaration.

PostgreSQL gets everything. MySQL, MariaDB, SQLite, and SQL Server run the same queries, writes, relations, tracking, and transactions, but no migrations yet, and they're covered by a smaller test suite. Databases spells out exactly what differs.

Where to next​