sqlc vs GORM and Ent for a Brand New Go Service Project
Choosing sqlc, GORM or Ent for a new Go service: a column rename shows when each tool tells you, plus benchmarks and a decision guide by team and schema

You are starting a Go service with a PostgreSQL database and have to pick how the code talks to it. The three names that come up are sqlc, GORM and Ent. The comparison articles usually benchmark them, and the benchmark rarely decides anything, because on a simple query they are within noise of each other. What differs is when the tool tells you that you made a mistake, how much code you write and read, and what the project looks like in a year. This article measures those, using one schema and the same operations for each, and ends with a guide by situation.
Tested with Go 1.25.6, sqlc v1.30.0, GORM v1.31.2, Ent v0.14.5, pgx v5.11.0, PostgreSQL 18.6 in Docker, macOS on Apple Silicon (arm64)
It is one of a set of comparisons: GORM vs sqlc vs Ent vs sqlx adds sqlx and database/sql, Ent vs GORM and Ent vs sqlc take the pairs, and the database cornerstone places them among the other options. Tutorials: sqlc, GORM, Ent.
The three, in one paragraph each
sqlc reads your SQL schema and your SQL queries and generates Go: a struct per table and a method per query. There is no runtime library to speak of. You write every query yourself, in SQL, and the compiler and the generator check them.
GORM maps structs to tables and builds SQL from method chains at run time. You describe models and relations in Go, call Create, Where, Preload, and GORM produces the SQL. Fast to start, flexible, and the most habit forming for people coming from other ORMs.
Ent is a code generator driven by a schema written in Go. You declare fields, edges and indexes, run the generator, and get a typed client: builders, predicates and eager loading for a graph of entities. Heavier to learn, with a large generated package and strong typing.
The setup for the numbers
The lab runs four operations (insert, lookup by email, list 50 orders, an account with its 50 orders) against the same accounts and orders tables, with each tool behind one Store interface. This version keeps only the three tools in question; the others are in the larger comparison.
package lab
import (
"context"
"fmt"
"os"
"github.com/jackc/pgx/v5/pgxpool"
)
// Store is the surface every implementation provides.
type Store interface {
CreateAccount(ctx context.Context, email, name string) (int64, error)
AccountByEmail(ctx context.Context, email string) (string, error)
ListOrders(ctx context.Context, accountID int64, limit int) (int, error)
AccountWithOrders(ctx context.Context, accountID int64) (int, error)
}
const base = "postgres://postgres:[email protected]:55432/lab?sslmode=disable"
func dsn(schema string) string { return base + "&search_path=" + schema }
// setup recreates a schema, creates the tables and seeds 200 accounts with 50 orders each.
func setup(ctx context.Context, schema string) error {
pool, err := pgxpool.New(ctx, base)
if err != nil {
return err
}
defer pool.Close()
ddl, err := os.ReadFile("schema.sql")
if err != nil {
return err
}
sql := fmt.Sprintf("drop schema if exists %[1]s cascade; create schema %[1]s; set search_path=%[1]s;", schema) + string(ddl)
if _, err := pool.Exec(ctx, sql); err != nil {
return err
}
_, err = pool.Exec(ctx, fmt.Sprintf(`
set search_path=%s;
insert into accounts (email, name) select 'seed'||g||'@x.io', 'Seed '||g from generate_series(1,200) g;
insert into orders (account_id, total_cents, status)
select a, 100*o, case when o %% 5 = 0 then 'refunded' else 'paid' end
from generate_series(1,200) a, generate_series(1,50) o;
analyze accounts; analyze orders;`, schema))
return err
}create table accounts (
id bigserial primary key,
email text not null unique,
name text not null,
created_at timestamptz not null default now()
);
create table orders (
id bigserial primary key,
account_id bigint not null references accounts(id),
total_cents int not null,
status text not null
);
create index orders_account_idx on orders (account_id);-- name: CreateAccount :one
insert into accounts (email, name) values ($1, $2) returning id;
-- name: AccountByEmail :one
select id, email, name, created_at from accounts where email = $1;
-- name: ListOrders :many
select id, account_id, total_cents, status from orders where account_id = $1 order by id limit $2;
-- name: AccountWithOrders :many
select a.id as account_id, a.name, o.id as order_id, o.total_cents
from accounts a join orders o on o.account_id = a.id
where a.id = $1 order by o.id;version: "2"
sql:
- engine: postgresql
schema: schema.sql
queries: query.sql
gen:
go:
package: db
out: db
sql_package: pgx/v5package lab
import (
"context"
"example.test/lab/db"
"github.com/jackc/pgx/v5/pgxpool"
)
type sqlcStore struct{ q *db.Queries }
func newSQLC(schema string) (Store, error) {
pool, err := pgxpool.New(context.Background(), dsn(schema))
if err != nil {
return nil, err
}
return sqlcStore{db.New(pool)}, nil
}
func (s sqlcStore) CreateAccount(ctx context.Context, email, name string) (int64, error) {
return s.q.CreateAccount(ctx, db.CreateAccountParams{Email: email, Name: name})
}
func (s sqlcStore) AccountByEmail(ctx context.Context, email string) (string, error) {
a, err := s.q.AccountByEmail(ctx, email)
return a.Name, err
}
func (s sqlcStore) ListOrders(ctx context.Context, accountID int64, limit int) (int, error) {
os, err := s.q.ListOrders(ctx, db.ListOrdersParams{AccountID: accountID, Limit: int32(limit)})
return len(os), err
}
func (s sqlcStore) AccountWithOrders(ctx context.Context, accountID int64) (int, error) {
rows, err := s.q.AccountWithOrders(ctx, accountID)
return len(rows), err
}package lab
import (
"context"
"time"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
type Account struct {
ID int64
Email string
Name string
CreatedAt time.Time
Orders []Order
}
type Order struct {
ID int64
AccountID int64
TotalCents int
Status string
}
type gormStore struct{ db *gorm.DB }
func newGORM(schema string) (Store, error) {
db, err := gorm.Open(postgres.Open(dsn(schema)), &gorm.Config{Logger: logger.Discard})
return gormStore{db}, err
}
func (s gormStore) CreateAccount(ctx context.Context, email, name string) (int64, error) {
a := Account{Email: email, Name: name}
err := s.db.WithContext(ctx).Create(&a).Error
return a.ID, err
}
func (s gormStore) AccountByEmail(ctx context.Context, email string) (string, error) {
var a Account
err := s.db.WithContext(ctx).Where("email = ?", email).Take(&a).Error
return a.Name, err
}
func (s gormStore) ListOrders(ctx context.Context, accountID int64, limit int) (int, error) {
var os []Order
err := s.db.WithContext(ctx).Where("account_id = ?", accountID).Order("id").Limit(limit).Find(&os).Error
return len(os), err
}
func (s gormStore) AccountWithOrders(ctx context.Context, accountID int64) (int, error) {
var a Account
err := s.db.WithContext(ctx).Preload("Orders").Take(&a, accountID).Error
return len(a.Orders), err
}package schema
import (
"entgo.io/ent"
"entgo.io/ent/dialect/entsql"
"entgo.io/ent/schema"
"entgo.io/ent/schema/edge"
"entgo.io/ent/schema/field"
"time"
)
type Account struct{ ent.Schema }
func (Account) Annotations() []schema.Annotation {
return []schema.Annotation{entsql.Annotation{Table: "accounts"}}
}
func (Account) Fields() []ent.Field {
return []ent.Field{
field.String("email").Unique(),
field.String("name"),
field.Time("created_at").Default(time.Now),
}
}
func (Account) Edges() []ent.Edge { return []ent.Edge{edge.To("orders", Order.Type)} }
type Order struct{ ent.Schema }
func (Order) Annotations() []schema.Annotation {
return []schema.Annotation{entsql.Annotation{Table: "orders"}}
}
func (Order) Fields() []ent.Field {
return []ent.Field{field.Int("account_id"), field.Int("total_cents"), field.String("status")}
}
func (Order) Edges() []ent.Edge {
return []ent.Edge{edge.From("account", Account.Type).Ref("orders").Unique().Required().Field("account_id")}
}package ent
//go:generate go run -mod=mod entgo.io/ent/cmd/ent generate --feature sql/upsert ./schemapackage lab
import (
"context"
"database/sql"
"entgo.io/ent/dialect"
entsql "entgo.io/ent/dialect/sql"
"example.test/lab/ent"
"example.test/lab/ent/account"
"example.test/lab/ent/order"
_ "github.com/jackc/pgx/v5/stdlib"
)
type entStore struct{ c *ent.Client }
func newEnt(schema string) (Store, error) {
db, err := sql.Open("pgx", dsn(schema))
if err != nil {
return nil, err
}
return entStore{ent.NewClient(ent.Driver(entsql.OpenDB(dialect.Postgres, db)))}, nil
}
func (s entStore) CreateAccount(ctx context.Context, email, name string) (int64, error) {
a, err := s.c.Account.Create().SetEmail(email).SetName(name).Save(ctx)
if err != nil {
return 0, err
}
return int64(a.ID), nil
}
func (s entStore) AccountByEmail(ctx context.Context, email string) (string, error) {
a, err := s.c.Account.Query().Where(account.Email(email)).Only(ctx)
if err != nil {
return "", err
}
return a.Name, nil
}
func (s entStore) ListOrders(ctx context.Context, accountID int64, limit int) (int, error) {
os, err := s.c.Order.Query().Where(order.AccountID(int(accountID))).Order(ent.Asc(order.FieldID)).Limit(limit).All(ctx)
return len(os), err
}
func (s entStore) AccountWithOrders(ctx context.Context, accountID int64) (int, error) {
a, err := s.c.Account.Query().Where(account.ID(int(accountID))).WithOrders().Only(ctx)
if err != nil {
return 0, err
}
return len(a.Edges.Orders), nil
}package lab
import (
"context"
"fmt"
"sync/atomic"
"testing"
)
var impls = []struct {
name string
make func(schema string) (Store, error)
}{
{"gorm", newGORM},
{"sqlc", newSQLC},
{"ent", newEnt},
}
var counter atomic.Int64
func each(b *testing.B, body func(b *testing.B, s Store)) {
ctx := context.Background()
if err := setup(ctx, "lab_cmp"); err != nil {
b.Fatal(err)
}
for _, im := range impls {
s, err := im.make("lab_cmp")
if err != nil {
b.Fatal(err)
}
b.Run(im.name, func(b *testing.B) {
b.ReportAllocs()
body(b, s)
})
}
}
func BenchmarkCreate(b *testing.B) {
each(b, func(b *testing.B, s Store) {
for i := 0; i < b.N; i++ {
if _, err := s.CreateAccount(context.Background(), fmt.Sprintf("n%d@x.io", counter.Add(1)), "N"); err != nil {
b.Fatal(err)
}
}
})
}
func BenchmarkGetByEmail(b *testing.B) {
each(b, func(b *testing.B, s Store) {
for i := 0; i < b.N; i++ {
if _, err := s.AccountByEmail(context.Background(), "[email protected]"); err != nil {
b.Fatal(err)
}
}
})
}
func BenchmarkListOrders(b *testing.B) {
each(b, func(b *testing.B, s Store) {
for i := 0; i < b.N; i++ {
if n, err := s.ListOrders(context.Background(), 7, 50); err != nil || n != 50 {
b.Fatal(n, err)
}
}
})
}
func BenchmarkAccountWithOrders(b *testing.B) {
each(b, func(b *testing.B, s Store) {
for i := 0; i < b.N; i++ {
if n, err := s.AccountWithOrders(context.Background(), 7); err != nil || n != 50 {
b.Fatal(n, err)
}
}
})
}set -e
~/go/bin/sqlc generate
GOFLAGS=-mod=mod go generate ./ent
go mod tidy 2>&1 | tail -3
go test -run=^$ -bench=. -benchtime=2s -count=1 -benchmem . 2>&1 | grep -E "^(Benchmark|ok|FAIL|---|[[:space:]]+bench)" | sed -E "s/-[0-9]+ / /"go: found github.com/rogpeppe/go-internal/fmtsort in github.com/rogpeppe/go-internal v1.16.0
BenchmarkCreate/gorm 2494 833155 ns/op 7350 B/op 93 allocs/op
BenchmarkCreate/sqlc 5145 418552 ns/op 509 B/op 11 allocs/op
BenchmarkCreate/ent 5949 413737 ns/op 2794 B/op 71 allocs/op
BenchmarkGetByEmail/gorm 9398 221500 ns/op 4987 B/op 70 allocs/op
BenchmarkGetByEmail/sqlc 10000 225378 ns/op 691 B/op 11 allocs/op
BenchmarkGetByEmail/ent 10000 228674 ns/op 4304 B/op 98 allocs/op
BenchmarkListOrders/gorm 2696 1138552 ns/op 19854 B/op 614 allocs/op
BenchmarkListOrders/sqlc 2793 946256 ns/op 12156 B/op 162 allocs/op
BenchmarkListOrders/ent 6483 320118 ns/op 19552 B/op 607 allocs/op
BenchmarkAccountWithOrders/gorm 4008 607949 ns/op 32677 B/op 814 allocs/op
BenchmarkAccountWithOrders/sqlc 8076 327218 ns/op 12292 B/op 162 allocs/op
BenchmarkAccountWithOrders/ent 3718 594867 ns/op 24672 B/op 700 allocs/op
ok example.test/lab 32.188sWhen does the tool tell you?
This is the question that decides a codebase's maintenance cost. Real services change schemas constantly, and the cost of a change is the time between making it and finding every query it broke. The lab renames the email column to email_address in a copy of the project and sees where each tool notices:
# Rename the email column in a copy and see where each tool notices.
rm -rf rename && mkdir rename
cp -r *.go *.sql *.yaml go.mod go.sum ent db rename/
cd rename
perl -pi -e 's/\bemail\b(\s+text not null unique)/email_address$1/' schema.sql
echo "== GORM: the struct still says email, the schema says email_address"
go test -run='^$' -bench='GetByEmail/gorm$' -benchtime=1x . 2>&1 | grep -E 'does not exist|^FAIL|^ok' | head -2
echo "== sqlc: generate reads the new schema"
~/go/bin/sqlc generate 2>&1 | head -2
echo "== Ent: rename the field in the schema, regenerate, build"
perl -pi -e 's/field\.String\("email"\)/field.String("email_address")/' ent/schema/schema.go
GOFLAGS=-mod=mod go generate ./ent > /dev/null 2>&1
go build . 2>&1 | head -3
cd .. && rm -rf rename== GORM: the struct still says email, the schema says email_address
bench_test.go:24: ERROR: column "email" of relation "accounts" does not exist (SQLSTATE 42703)
FAIL
== sqlc: generate reads the new schema
# package db
query.sql:2:23: column "email" does not exist
== Ent: rename the field in the schema, regenerate, build
# example.test/lab
./impl_ent.go:26:33: s.c.Account.Create().SetEmail undefined (type *"example.test/lab/ent".AccountCreate has no field or method SetEmail)
./impl_ent.go:34:46: undefined: account.Email- GORM compiled without complaint. The struct field
Emailstill maps to a column namedemail, which no longer exists, and the first query that ran failed withERROR: column "email" of relation "accounts" does not exist (SQLSTATE 42703). The error is clear. It arrives at run time, in a test if you have one that executes the query, in production if you do not. The mapping between struct and table is a string in a tag or a naming convention, and the compiler cannot see it. - sqlc failed at generation:
query.sql:2:23: column "email" does not exist. The line and column point at the word. No Go was compiled and no database was needed, because sqlc checks the queries against the schema file. - Ent regenerated its client from the changed schema, and then
go buildfailed in the code that used the old name:SetEmail undefinedandundefined: account.Email. The compiler lists every call site, which is the experience of renaming a Go function.
This is the strongest argument for the generators in a service that will live for years. GORM can reach the same safety with tests that run every query against a real database (GORM testing), and then the check costs a test suite, not a compiler. If your team does not have that discipline yet, the generators build it in.
What the benchmark shows
| Operation | sqlc | GORM | Ent |
|---|---|---|---|
| Insert (µs) | 419 | 833 | 414 |
| Lookup by email (µs) | 225 | 222 | 229 |
| List 50 orders (µs) | 946 | 1139 | 320 |
| Account with 50 orders (µs) | 327 | 608 | 595 |
| Allocations, lookup | 11 | 70 | 98 |
| Allocations, list of 50 | 162 | 614 | 607 |
A lookup is 222 to 229 µs for all three, which is the PostgreSQL round trip. The insert gap is GORM's default transaction (BEGIN, INSERT, COMMIT), a setting to know about, not a limit of the library (performance mistakes). The list gap in Ent's favour is not Ent being fast: it writes LIMIT 50 into the SQL while the others bind it as a parameter, and PostgreSQL planned the parameterised version badly. The larger comparison shows the plan, and the point holds here too: a benchmark can measure the statement and not the tool.
The numbers that do belong to the tools are allocations. sqlc made 11 for a lookup and 162 for the list. GORM and Ent made several times as many. That matters on a hot path of a busy service, and not on a back office screen.
Decision guide
Choose sqlc if:
- The team is comfortable with SQL, and a reviewer reading SQL is a feature.
- You want the compiler to check queries, and the lowest overhead.
- The service has many hand tuned queries, joins and reports. A query builder would fight you.
- You can accept writing each query, including simple ones. Dynamic filters need a pattern (dynamic queries with sqlc).
Choose GORM if:
- Speed to a working API matters more than the last 10 percent of correctness tooling.
- The schema is mostly CRUD on many small tables, with simple relations.
- People already know it, and you will write tests against a real database.
- You accept learning its defaults: transactions on writes,
Savewrites every field, zero values skipped in struct updates (the tutorial).
Choose Ent if:
- The domain is a graph: users, groups, permissions, documents, with many relations traversed in queries.
- You want typed predicates and eager loading generated for you, and extensions like privacy rules or GraphQL.
- You are fine with a large generated package committed to the repository and a generation step in CI.
Avoid mixing three. A service with sqlc, GORM and Ent all in one module has three mental models, three migration stories and three ways to be wrong. Mixing two is common and sensible (an ORM for CRUD, sqlc for reports). Mixing three is a sign nobody decided.
The cost of changing your mind
Choosing wrong is survivable when the data access sits behind an interface. Migrating from GORM to sqlc gradually works because both can share one connection pool and even one transaction. From Ent or GORM to the other needs a rewrite of the models, since both own the schema description. Moving to sqlc from anything is the easiest direction, because the schema is plain SQL and the queries are the thing you were going to write anyway.
Whichever you pick, keep migrations as versioned SQL files that the database tooling owns, not as a by product of the ORM. That one decision keeps every other choice reversible.
Checklist
- Rename a column in a scratch copy and see how each candidate reacts. That test is worth more than a benchmark.
- Compare allocations and generated code size, not just latency.
- Decide who owns the schema before choosing the library.
- Put the data layer behind an interface from day one.
- Pick one primary tool and at most one companion.


