The Best Go ORMs Ranked by What You Are Building Next
The best Go ORMs ranked by project type: GORM, bun, ent and the SQL generators, with the SQL each emits, soft delete behaviour and zero value handling

An ORM in Go is a bigger decision than in most languages, because Go culture is suspicious of magic and the standard library gives you a good SQL interface. Still, plenty of teams want mapped models, associations and migrations without writing every query, and several libraries compete for that job. They differ in philosophy more than in features: one generates SQL from your Go values with conventions, one stays close to the SQL, one models a graph, and the generators write typed code from your schema. This roundup ranks them by what you are building, with the SQL two of them emit, run on Go 1.25.6 and PostgreSQL 18.6.
Tested with Go 1.25.6, GORM v1.31.2, bun v1.3.0, pgx v5.11.0, PostgreSQL 18.6 in Docker, macOS on Apple Silicon (arm64). ent, sqlboiler and others were not run and are described from their documentation.
It belongs to the database cornerstone. The argument for and against ORMs is in ORM or raw SQL, where GORM's SQL is shown, and the speed comparison is in the library comparison. Only GORM and bun were run; the others are described from their documentation and marked.
How the contenders differ
| Library | Approach | Models | Migrations | Strength | Watch for |
|---|---|---|---|---|---|
| GORM | Conventions, method chaining | Go structs with tags | AutoMigrate, plus external | Largest ecosystem, quick CRUD | Zero value rules, default transactions, magic |
| bun | SQL first builder over database/sql | Go structs with tags | Own migrator, or external | Close to SQL, good PostgreSQL support | Smaller community, explicit zero handling |
| ent | Generated typed API from Go schemas | Schema as Go code | Built in, or Atlas | Entity graphs, traversal, type safety | Code generation and learning its model |
| sqlboiler | Generates models from an existing schema | Generated from the database | External | Typed code that matches the database | Regeneration on every schema change |
| sqlc | Generates functions from SQL | None, queries only | External | SQL as source, compile time checks | Dynamic queries, not an ORM |
What bun sends
The GORM side is in the ORM or SQL article. This lab runs the same kind of tasks through bun and records the SQL through a query hook, with timestamps masked so the output is stable.
package main
import (
"context"
"database/sql"
"fmt"
"log"
"regexp"
"strings"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect/pgdialect"
)
type Author struct {
bun.BaseModel `bun:"table:authors651"`
ID int64 `bun:",pk,autoincrement"`
Name string `bun:",notnull"`
Books []*Book `bun:"rel:has-many,join:id=author_id"`
}
type Book struct {
bun.BaseModel `bun:"table:books651"`
ID int64 `bun:",pk,autoincrement"`
Title string
Pages int
AuthorID int64
Author *Author `bun:"rel:belongs-to,join:author_id=id"`
DeletedAt time.Time `bun:",soft_delete,nullzero"`
}
type recorder struct{ out *[]string }
// stamp hides the clock: a timestamp in a printed statement would change on every run.
var stamp = regexp.MustCompile(`'\d{4}-\d{2}-\d{2} [^']*'`)
func (recorder) BeforeQuery(ctx context.Context, _ *bun.QueryEvent) context.Context { return ctx }
func (r recorder) AfterQuery(_ context.Context, e *bun.QueryEvent) {
*r.out = append(*r.out, stamp.ReplaceAllString(strings.Join(strings.Fields(e.Query), " "), "'<timestamp>'"))
}
func main() {
ctx := context.Background()
std, err := sql.Open("pgx", "postgres://postgres:[email protected]:55432/lab?sslmode=disable")
if err != nil {
log.Fatal(err)
}
db := bun.NewDB(std, pgdialect.New())
var stmts []string
db.AddQueryHook(recorder{&stmts})
for _, q := range []string{"drop table if exists books651", "drop table if exists authors651"} {
if _, err := db.ExecContext(ctx, q); err != nil {
log.Fatal(err)
}
}
for _, m := range []any{(*Author)(nil), (*Book)(nil)} {
if _, err := db.NewCreateTable().Model(m).Exec(ctx); err != nil {
log.Fatal(err)
}
}
show := func(label string) {
fmt.Println(label)
for _, s := range stmts {
fmt.Println(" " + s)
}
stmts = stmts[:0]
}
stmts = stmts[:0]
// 1. Bulk insert is one statement.
authors := []*Author{{Name: "Ada"}, {Name: "Grace"}}
if _, err := db.NewInsert().Model(&authors).Exec(ctx); err != nil {
log.Fatal(err)
}
show("1. insert two authors with one call")
books := []*Book{{Title: "Engines", Pages: 200, AuthorID: authors[0].ID}, {Title: "Notes", Pages: 90, AuthorID: authors[0].ID}, {Title: "Compilers", Pages: 310, AuthorID: authors[1].ID}}
if _, err := db.NewInsert().Model(&books).Exec(ctx); err != nil {
log.Fatal(err)
}
show("1b. insert three books with one call")
// 2. Relations: belongs-to is a join, has-many is a second query.
var withAuthor []Book
if err := db.NewSelect().Model(&withAuthor).Relation("Author").Order("book.id").Scan(ctx); err != nil {
log.Fatal(err)
}
show("2. Relation(\"Author\") on books (belongs-to)")
var withBooks []Author
if err := db.NewSelect().Model(&withBooks).Relation("Books").Order("author.id").Scan(ctx); err != nil {
log.Fatal(err)
}
show("2b. Relation(\"Books\") on authors (has-many)")
fmt.Printf(" first author has %d books loaded\n", len(withBooks[0].Books))
// 3. Updates: a struct writes every column unless told otherwise.
b := Book{ID: books[0].ID, Title: "Engines, second edition"}
if _, err := db.NewUpdate().Model(&b).WherePK().Exec(ctx); err != nil {
log.Fatal(err)
}
show("3. Update(&Book{ID, Title}) writes the zero Pages and AuthorID as well")
if _, err := db.NewUpdate().Model(&b).OmitZero().WherePK().Exec(ctx); err != nil {
log.Fatal(err)
}
show("3b. the same with OmitZero()")
// 4. Soft delete and its filters.
if _, err := db.NewDelete().Model(&Book{ID: books[1].ID}).WherePK().Exec(ctx); err != nil {
log.Fatal(err)
}
show("4. Delete on a model with a soft_delete column")
var live, all int64
live, _ = db.NewSelect().Model((*Book)(nil)).Count(ctx)
all, _ = db.NewSelect().Model((*Book)(nil)).WhereAllWithDeleted().Count(ctx)
show(fmt.Sprintf("4b. Count: %d live, %d with deleted", live, all))
// 5. Raw SQL and IN lists.
ids := []int64{books[0].ID, books[2].ID}
var titles []string
if err := db.NewSelect().Model((*Book)(nil)).Column("title").Where("id IN (?)", bun.In(ids)).Order("id").Scan(ctx, &titles); err != nil {
log.Fatal(err)
}
show(fmt.Sprintf("5. Where(\"id IN (?)\", bun.In(ids)) -> %v", titles))
// 6. The query as a string, without running it.
fmt.Println("6. query built but not run:", db.NewSelect().Model((*Book)(nil)).Where("pages > ?", 100).Limit(5).String())
}1. insert two authors with one call
INSERT INTO "authors651" ("id", "name") VALUES (DEFAULT, 'Ada'), (DEFAULT, 'Grace') RETURNING "id"
1b. insert three books with one call
INSERT INTO "books651" ("id", "title", "pages", "author_id", "deleted_at") VALUES (DEFAULT, 'Engines', 200, 1, DEFAULT), (DEFAULT, 'Notes', 90, 1, DEFAULT), (DEFAULT, 'Compilers', 310, 2, DEFAULT) RETURNING "id", "deleted_at"
2. Relation("Author") on books (belongs-to)
SELECT "book"."id", "book"."title", "book"."pages", "book"."author_id", "book"."deleted_at", "author"."id" AS "author__id", "author"."name" AS "author__name" FROM "books651" AS "book" LEFT JOIN "authors651" AS "author" ON ("author"."id" = "book"."author_id") WHERE "book"."deleted_at" IS NULL ORDER BY "book"."id"
2b. Relation("Books") on authors (has-many)
SELECT "author"."id", "author"."name" FROM "authors651" AS "author" ORDER BY "author"."id"
SELECT "book"."id", "book"."title", "book"."pages", "book"."author_id", "book"."deleted_at" FROM "books651" AS "book" WHERE ("book"."author_id" IN (1, 2)) AND "book"."deleted_at" IS NULL
first author has 2 books loaded
3. Update(&Book{ID, Title}) writes the zero Pages and AuthorID as well
UPDATE "books651" AS "book" SET "title" = 'Engines, second edition', "pages" = 0, "author_id" = 0, "deleted_at" = DEFAULT WHERE "book"."deleted_at" IS NULL AND ("book"."id" = 1)
3b. the same with OmitZero()
UPDATE "books651" AS "book" SET "title" = 'Engines, second edition' WHERE "book"."deleted_at" IS NULL AND ("book"."id" = 1)
4. Delete on a model with a soft_delete column
UPDATE "books651" AS "book" SET "deleted_at" = '<timestamp>' WHERE "book"."deleted_at" IS NULL AND ("book"."id" = 2)
4b. Count: 2 live, 3 with deleted
SELECT count(*) FROM "books651" AS "book" WHERE "book"."deleted_at" IS NULL
SELECT count(*) FROM "books651" AS "book"
5. Where("id IN (?)", bun.In(ids)) -> [Engines, second edition Compilers]
SELECT "book"."title" FROM "books651" AS "book" WHERE (id IN (1, 3)) AND "book"."deleted_at" IS NULL ORDER BY "id"
6. query built but not run: SELECT "book"."id", "book"."title", "book"."pages", "book"."author_id", "book"."deleted_at" FROM "books651" AS "book" WHERE (pages > 100) AND "book"."deleted_at" IS NULL LIMIT 5Bulk inserts are one statement. Passing a slice of two authors to NewInsert().Model(&authors) sent a single INSERT ... VALUES (DEFAULT, 'Ada'), (DEFAULT, 'Grace') RETURNING "id", and the three books likewise, with the generated ids written back to the structs. This is better than a loop, and it is the default.
Relations are explicit. Relation("Author") on books, a belongs-to, produced one LEFT JOIN with the author's columns aliased as author__id and author__name. Relation("Books") on authors, a has-many, produced two statements: the authors, then the books with WHERE "book"."author_id" IN (1, 2). That is the choice GORM exposes as Joins and Preload, here made by the relation type, and it avoids both an N plus one and the row multiplication of a join over a to-many relation. Nothing is loaded unless you ask for the relation by name, so there is no lazy loading to surprise you.
Zero values are written unless you say otherwise. Updating Book{ID: 1, Title: "..."} produced SET "title" = '...', "pages" = 0, "author_id" = 0, "deleted_at" = DEFAULT: the zero Pages and AuthorID overwrote real data. OmitZero() fixed it, producing SET "title" = '...' only. This is the opposite default to GORM, which skips zero values in struct updates and filters. Neither is wrong, and each has a failure mode: GORM cannot write a zero from a struct, bun writes zeros you did not mean. Use OmitZero, pointer fields or a Set list in bun, and a map or Select in GORM, for partial updates, and write tests that update one field and assert the others survive.
Soft delete is a model feature. A DeletedAt column tagged soft_delete made Delete an UPDATE ... SET "deleted_at" = '<timestamp>', and every select, update and join gained WHERE "book"."deleted_at" IS NULL, as the statements show. Counting live books returned 2 and WhereAllWithDeleted() counted 3. Convenient, with the costs described in soft delete patterns: unique keys, forgotten filters in raw SQL and rows that never leave.
Queries can be inspected. The last block builds a query and prints it with .String() without running it: SELECT ... WHERE (pages > 100) AND "book"."deleted_at" IS NULL LIMIT 5. That makes a test of generated SQL possible and review of a complex builder call practical. GORM offers the same through a dry run session.
Ranking by what you are building
A CRUD heavy application with a small team and a deadline: GORM. The widest adoption means the most examples, plugins and answers. It does the common things in one line, and the pitfalls are well documented. Run it with statement logging in development, turn off the default transaction if your writes are single statements, and keep its zero value behaviour in mind.
A service where you think in SQL: bun. The builder reads like the query it generates, relations are explicit and PostgreSQL types (arrays, JSONB) are supported well. It asks you to know SQL, and it will not hide a join. It is the better fit for a team that wants an ORM's mapping without an ORM's conventions.
A domain that is a graph of entities, such as users, groups, permissions and resources: ent. Not run here. It defines the schema as Go code and generates a typed query API with traversals (user.QueryGroups().QueryPermissions()), eager loading and hooks, with migrations through its own tool or Atlas. Its strength is type safety and expressive relations; the cost is a generation step, a mental model of its own and a less direct view of the SQL.
Typed code from a schema you already have: sqlboiler or sqlc. sqlboiler introspects an existing database and generates models and query helpers, which suits legacy schemas. sqlc generates functions from your SQL, which suits teams that prefer to write queries. sqlboiler was not run here; sqlc is measured in the comparison. Choose them when you want compile time safety and are happy with generated code in the repository.
A performance sensitive or complex query service: no ORM, or one beside raw SQL. pgx with sqlc, or sqlx, and an ORM only for the simple tables. See pgx explained.
A prototype or internal tool: GORM with AutoMigrate. It is fast to start. Replace auto migration with real migrations before production.
Migrations and schema ownership
The part of an ORM that outlives your first month is the schema story. Two models exist. In the code first model the Go structs define the schema and the tool creates or alters tables to match: GORM's AutoMigrate, ent's schema diffing. It is quick, and it hides the migration, so the SQL that runs against production was never written down or reviewed, and a renamed field becomes a dropped column. In the schema first model the database schema is the source, defined in migration files, and the Go side is generated from it (sqlboiler, sqlc) or written to match.
For anything that holds data you care about, write the migrations, even if you generate a draft with the ORM's tool, and review them as you would application code: lock behaviour, backfills and a plan to roll forward. Zero downtime migrations covers what can go wrong on a live table, and the migration tools roundup compares the runners. An ORM's convenience at table creation is never a reason to let it alter a production schema.
What to check before you adopt one
- Print the SQL of your three most important queries and check you would have written them. If you do not like what you see, it will not get better at scale.
- Test partial updates and filters with zero values. The libraries disagree, and the bug is silent.
- Count statements for a list endpoint with associations, to catch N plus one.
- Check the transaction defaults for single writes and for hooks.
- Look at the escape hatch: how do you run a raw query and scan it into a struct? It should be easy, because you will need it.
- Read the migration story, and decide whether you will generate migrations or write them. Writing them is safer.
- Check maintenance: release cadence, open issues, Go version support. Several of these libraries needed newer Go releases than this series uses, so pin versions.
The documentation for each is at GORM, bun, ent and sqlboiler, and the generated SQL quoted above comes from the lab on this page.


