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/antNo 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
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.
ant.Gt(&U.Age, 18)WHERE "users"."age" > $1$1 = 18- &U.Age becomes "users"."age"
- It points at the
Agefield of yourUserstruct, so ant knows the column. Rename the field and every query follows. - 18 becomes $1
- It has to be an
int, becauseAgeis 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.
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)
The typo goes straight into the SQL, and you hear about it from the database.
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)
Same story. It reads nicely, like SQL, but to the compiler it's just a string.
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.
users, err := db.Users.
Where(ant.Lte(&U.Agee, 18)).
All(ctx)
U.Agee undefined (type *User has no field or method Agee)
SELECT "users"."id", ... FROM "users" WHERE "users"."age" <= $1
- The typo never gets as far as the database. Your build stops it.
&U.Ageis a*int, so the value has to be aninttoo.- 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"age36activetrue
ant SaveChanges
The database now holds
name"Ada"age0activetrue
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
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.
_, err := db.NewUpdate().Model(&user).
OmitZero().
WherePK().
Exec(ctx)
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.
engine.ID(id).Update(&User{
Name: "Ada",
Age: 0,
})
The same rule, unless you name the columns with Cols or AllCols.
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,"", andnilcount 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
defaultleaves 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.
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
That's the default: one query per user. EagerPreload batches them, if you know to reach for it.
err := db.Preload("Posts").Find(&users).Error
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.
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))
Batched too, with the same string.
users, err := db.Users.
Include(&U.Posts).
All(ctx)
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.
name = "Ada L.", plan = "free"The upgrade is gone, and nothing reported an error.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
Save writes every field, including the plan it loaded before the upgrade. Bun's Update on a model does the same.
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.
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.
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
- Only
nameis written, so the plan stayspro. - 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
- Write your structsThey're the schema.
migrate diff create_tablesWrites the first SQL file. No database needed.- Read the fileIt's plain SQL, and yours to edit.
migrate upApplies it, and records that it ran.
Bringing an existing database
pg_dump --schema-onlyYour schema, as it is today.- Write structs to matchBy hand, or with an AI assistant and this prompt.
migrate driftLists every difference. Fix the structs until there are none left.migrate adoptYour dump becomes migration 0001.baselinemarks it as already applied.
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.
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.
baselinerecords 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.
diffandcheckdon'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.
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
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.
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 does not perform database migrations for you.
That's straight from its docs. It reads the files of whichever migration tool you pick.
Wrote migrations/versions/0002_add_bio.up.sql
add column users.bio
Review it, then apply it with: up
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.