GORM vs sqlc vs Ent vs sqlx and Which One Fits You
One schema, four Go data layers and plain database/sql, benchmarked on PostgreSQL 18: speed, allocations, lines of code and generated code, with a verdict

Every few months someone asks which data access layer a new Go service should use, and the answers are mostly taste, usually backed by a benchmark that measured something else. This article runs the same four operations through GORM, sqlc, Ent, sqlx and plain database/sql against one PostgreSQL 18.6 schema, so the numbers are comparable, and then reads them honestly. The result is less dramatic than the benchmark wars suggest: the tools differ in allocations, write defaults and how much code you write, and almost not at all in the latency of a simple query.
Tested with Go 1.25.6, GORM v1.31.2, sqlc v1.30.0, Ent v0.14.5, sqlx v1.4.0, pgx v5.11.0, PostgreSQL 18.6 in Docker, macOS on Apple Silicon (arm64)
Each tool has its own tutorial in this cluster: GORM, sqlc, Ent, sqlx and database/sql. For the broader choice among them, see choosing a Go database library, and for pairwise versions of this experiment, Ent vs GORM and GORM vs database/sql.
The setup
Two tables, accounts and orders (50 orders for each of 200 accounts, with a foreign key and an index on orders.account_id). Every implementation provides the same four operations behind one interface:
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
}CreateAccount: one insert returning the id.AccountByEmail: a lookup on a unique column.ListOrders: 50 rows of one account, ordered by id, with aLIMIT.AccountWithOrders: an account and its 50 orders (a join or an eager load).
All five use the pgx driver. database/sql and sqlx use its stdlib adapter, GORM its PostgreSQL driver, sqlc its pgx mode over a pgxpool, and Ent a database/sql handle. The benchmark runs each operation for two seconds per implementation, one goroutine, against a local PostgreSQL in Docker. Schema:
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);The five implementations
Plain database/sql, the baseline everyone else is compared to:
package lab
import (
"context"
"database/sql"
_ "github.com/jackc/pgx/v5/stdlib"
)
type sqlStore struct{ db *sql.DB }
func newSQL(schema string) (Store, error) {
db, err := sql.Open("pgx", dsn(schema))
return sqlStore{db}, err
}
func (s sqlStore) CreateAccount(ctx context.Context, email, name string) (int64, error) {
var id int64
err := s.db.QueryRowContext(ctx, `insert into accounts (email, name) values ($1, $2) returning id`, email, name).Scan(&id)
return id, err
}
func (s sqlStore) AccountByEmail(ctx context.Context, email string) (string, error) {
var id int64
var e, name string
var created any
err := s.db.QueryRowContext(ctx, `select id, email, name, created_at from accounts where email = $1`, email).Scan(&id, &e, &name, &created)
return name, err
}
func (s sqlStore) ListOrders(ctx context.Context, accountID int64, limit int) (int, error) {
rows, err := s.db.QueryContext(ctx, `select id, account_id, total_cents, status from orders where account_id = $1 order by id limit $2`, accountID, limit)
if err != nil {
return 0, err
}
defer rows.Close()
n := 0
for rows.Next() {
var id, acc int64
var total int
var status string
if err := rows.Scan(&id, &acc, &total, &status); err != nil {
return 0, err
}
n++
}
return n, rows.Err()
}
func (s sqlStore) AccountWithOrders(ctx context.Context, accountID int64) (int, error) {
rows, err := s.db.QueryContext(ctx, `select a.id, a.name, o.id, o.total_cents from accounts a join orders o on o.account_id = a.id where a.id = $1 order by o.id`, accountID)
if err != nil {
return 0, err
}
defer rows.Close()
n := 0
for rows.Next() {
var aid, oid int64
var name string
var total int
if err := rows.Scan(&aid, &name, &oid, &total); err != nil {
return 0, err
}
n++
}
return n, rows.Err()
}sqlx, which keeps your SQL and removes the scanning boilerplate:
package lab
import (
"context"
_ "github.com/jackc/pgx/v5/stdlib"
"github.com/jmoiron/sqlx"
)
type sqlxStore struct{ db *sqlx.DB }
func newSQLX(schema string) (Store, error) {
db, err := sqlx.Open("pgx", dsn(schema))
return sqlxStore{db}, err
}
type xAccount struct {
ID int64 `db:"id"`
Email string `db:"email"`
Name string `db:"name"`
CreatedAt any `db:"created_at"`
}
type xOrder struct {
ID int64 `db:"id"`
AccountID int64 `db:"account_id"`
TotalCents int `db:"total_cents"`
Status string `db:"status"`
}
func (s sqlxStore) CreateAccount(ctx context.Context, email, name string) (int64, error) {
var id int64
err := s.db.GetContext(ctx, &id, `insert into accounts (email, name) values ($1, $2) returning id`, email, name)
return id, err
}
func (s sqlxStore) AccountByEmail(ctx context.Context, email string) (string, error) {
var a xAccount
err := s.db.GetContext(ctx, &a, `select id, email, name, created_at from accounts where email = $1`, email)
return a.Name, err
}
func (s sqlxStore) ListOrders(ctx context.Context, accountID int64, limit int) (int, error) {
var os []xOrder
err := s.db.SelectContext(ctx, &os, `select id, account_id, total_cents, status from orders where account_id = $1 order by id limit $2`, accountID, limit)
return len(os), err
}
func (s sqlxStore) AccountWithOrders(ctx context.Context, accountID int64) (int, error) {
var rows []struct {
AID int64 `db:"aid"`
Name string `db:"name"`
OID int64 `db:"oid"`
Total int `db:"total_cents"`
}
err := s.db.SelectContext(ctx, &rows, `select a.id as aid, a.name, o.id as oid, o.total_cents from accounts a join orders o on o.account_id = a.id where a.id = $1 order by o.id`, accountID)
return len(rows), err
}GORM, with models and a preload:
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
}sqlc, from SQL files to generated methods:
-- 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
}Ent, from a schema in Go:
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
}The benchmark
package lab
import (
"context"
"fmt"
"sync/atomic"
"testing"
)
var impls = []struct {
name string
make func(schema string) (Store, error)
}{
{"database/sql", newSQL},
{"gorm", newGORM},
{"sqlx", newSQLX},
{"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]+ / /"BenchmarkCreate/database/sql 4988 419277 ns/op 872 B/op 19 allocs/op
BenchmarkCreate/gorm 2778 866442 ns/op 7355 B/op 93 allocs/op
BenchmarkCreate/sqlx 5386 431451 ns/op 969 B/op 23 allocs/op
BenchmarkCreate/sqlc 4956 424791 ns/op 509 B/op 11 allocs/op
BenchmarkCreate/ent 5586 446471 ns/op 2794 B/op 71 allocs/op
BenchmarkGetByEmail/database/sql 10000 220172 ns/op 1184 B/op 31 allocs/op
BenchmarkGetByEmail/gorm 9517 214635 ns/op 4989 B/op 70 allocs/op
BenchmarkGetByEmail/sqlx 10000 216687 ns/op 1424 B/op 33 allocs/op
BenchmarkGetByEmail/sqlc 10000 218892 ns/op 691 B/op 11 allocs/op
BenchmarkGetByEmail/ent 10000 230164 ns/op 4304 B/op 98 allocs/op
BenchmarkListOrders/database/sql 2511 1111965 ns/op 5392 B/op 416 allocs/op
BenchmarkListOrders/gorm 2688 1031133 ns/op 19843 B/op 614 allocs/op
BenchmarkListOrders/sqlx 2277 1074366 ns/op 12776 B/op 378 allocs/op
BenchmarkListOrders/sqlc 2534 1013052 ns/op 12156 B/op 162 allocs/op
BenchmarkListOrders/ent 6648 323417 ns/op 19552 B/op 607 allocs/op
BenchmarkAccountWithOrders/database/sql 6850 303734 ns/op 5360 B/op 416 allocs/op
BenchmarkAccountWithOrders/gorm 4339 549926 ns/op 32649 B/op 814 allocs/op
BenchmarkAccountWithOrders/sqlx 6682 337640 ns/op 13016 B/op 378 allocs/op
BenchmarkAccountWithOrders/sqlc 7900 308248 ns/op 12292 B/op 162 allocs/op
BenchmarkAccountWithOrders/ent 4149 517000 ns/op 24672 B/op 700 allocs/op
ok example.test/lab 48.594sResults
| Operation | database/sql | sqlx | sqlc | GORM | Ent |
|---|---|---|---|---|---|
| Create (µs) | 419 | 431 | 425 | 866 | 446 |
| Lookup by email (µs) | 220 | 217 | 219 | 215 | 230 |
| List 50 orders (µs) | 1112 | 1074 | 1013 | 1031 | 323 |
| Account with orders (µs) | 304 | 338 | 308 | 550 | 517 |
| Allocations per call | database/sql | sqlx | sqlc | GORM | Ent |
|---|---|---|---|---|---|
| Create | 19 | 23 | 11 | 93 | 71 |
| Lookup | 31 | 33 | 11 | 70 | 98 |
| List 50 | 416 | 378 | 162 | 614 | 607 |
| With orders | 416 | 378 | 162 | 814 | 700 |
What the numbers say
Latency of a simple query is the database. The lookup took 215 to 230 µs everywhere. Nearly all of it is the round trip and PostgreSQL's work, and the Go layer is a rounding error. If someone tells you a tool is "faster" on single row reads, they measured noise.
Allocations are where the tools differ. sqlc made 11 allocations for a lookup and Ent 98, a factor of nine. For a 50 row list it was 162 against 607 and 614. The cost of an allocation is small, and it adds up in a service that handles thousands of requests per second, in GC pressure and in memory churn, which is why the difference matters for a hot path and not for an admin screen. sqlc generates the scan code you would write by hand, and the reflection based layers (GORM, Ent's edge assembly, sqlx's mapper) pay for flexibility.
GORM's insert is twice as slow, and that is a default. GORM wraps each write in a transaction, which adds BEGIN and COMMIT round trips. At 866 µs against about 420 for everything else, it is the largest gap in the table. It is a setting (SkipDefaultTransaction), and the same effect appears at larger scale in batch insert and the performance mistakes list. Know the default before you compare.
Joins and eager loads cost more in the ORMs. Loading an account with its 50 orders took 304 µs with a join in plain SQL and 308 in sqlc, and 550 (GORM, a preload: two queries) and 517 (Ent, eager loading: two queries). Two round trips cost more than one, and the allocations double. When one join does the job, writing the SQL wins.
The surprise: LIMIT $2
The list operation produced a result that looks like Ent winning: 323 µs against about 1 ms for the other four. It is not Ent. The other four send the limit as a bind parameter (limit $2), and Ent writes LIMIT 50 into the statement. The difference is in PostgreSQL's plan:
docker exec -i gh-lab-pg psql -U postgres lab <<'SQL'
set search_path = lab_cmp;
select 'literal limit 50: the planner knows the value' as plan;
explain (analyze, costs off, timing off, summary off)
select id from orders where account_id = 7 order by id limit 50;
select 'parameterised limit, generic plan' as plan;
set plan_cache_mode = force_generic_plan;
prepare p(bigint, bigint) as select id from orders where account_id = $1 order by id limit $2;
explain (analyze, costs off, timing off, summary off) execute p(7, 50);
SQLSET
plan
-----------------------------------------------
literal limit 50: the planner knows the value
(1 row)
QUERY PLAN
---------------------------------------------------------------------------------------
Limit (actual rows=50.00 loops=1)
Buffers: shared hit=58
-> Sort (actual rows=50.00 loops=1)
Sort Key: id
Sort Method: quicksort Memory: 25kB
Buffers: shared hit=58
-> Bitmap Heap Scan on orders (actual rows=50.00 loops=1)
Recheck Cond: (account_id = 7)
Heap Blocks: exact=50
Buffers: shared hit=55
-> Bitmap Index Scan on orders_account_idx (actual rows=50.00 loops=1)
Index Cond: (account_id = 7)
Index Searches: 1
Buffers: shared hit=5
Planning:
Buffers: shared hit=91
(16 rows)
plan
-----------------------------------
parameterised limit, generic plan
(1 row)
SET
PREPARE
QUERY PLAN
--------------------------------------------------------------------------
Limit (actual rows=50.00 loops=1)
Buffers: shared hit=101 dirtied=70
-> Index Scan using orders_pkey on orders (actual rows=50.00 loops=1)
Filter: (account_id = $1)
Rows Removed by Filter: 9757
Index Searches: 1
Buffers: shared hit=101 dirtied=70
(7 rows)With the literal, PostgreSQL knew it needed 50 rows for one account and used a bitmap scan on orders_account_idx. With a parameter, a prepared statement can be planned generically, without the value, and the planner then bets that it can read the primary key in order and stop after $2 rows. It was wrong here: Index Scan using orders_pkey with Rows Removed by Filter: 9757, reading about 10000 rows to find 50. The pgx driver prepares statements and PostgreSQL switches to a generic plan after several executions, which is why this appears in a loop and not in a one-off query.
The lesson is general. A benchmark between libraries can be measuring the SQL they send. If you see a large gap between tools on the same query, compare the statements and run EXPLAIN on each. The fixes for the slow tools are to inline a validated LIMIT, to use plan_cache_mode = force_custom_plan for the connection, or to add an index that makes both plans cheap (a composite index on (account_id, id) serves the filter and the order). I did not measure the composite index here.
Code you write, and code you read
| Hand written lines | Generated lines | |
|---|---|---|
database/sql | 67 | 0 |
| sqlx | 58 | 0 |
| GORM | 56 | 0 |
| sqlc | 37 + 13 SQL + 10 config | 169 |
| Ent | 52 + 40 schema | 7331 |
Ent's generated code is the number to notice. Two entities produced 7331 lines in the ent package: builders, predicates, mutations, hooks and migration code for every field and edge. You do not read it, you do commit it (or generate it in CI), and it affects compile time and review noise. sqlc's 169 lines are what you would write by hand. GORM generates nothing and does its work at run time.
Which one fits
- sqlc: the team knows SQL, queries are reviewed as SQL, and you want compile time checks with minimal run time cost. The best allocation numbers, and the least magic. You write every query.
- GORM: fastest to a working CRUD service, and the most familiar to people from other languages. Know the defaults (transactions on every write,
Savewrites everything, associations save by default) and count queries. See the GORM articles. - Ent: schemas with many relationships, where graph traversal, typed predicates and extensions (privacy rules, GraphQL, OpenAPI) pay for the generated code. Expect a steeper start and a large generated package.
- sqlx: you want to keep writing SQL and not write
Scancalls. A small step up fromdatabase/sql, with the allocation cost of reflection. database/sqlwith pgx: the smallest dependency set, and the right answer for a handful of queries.
None of these is wrong. Mixing is normal: GORM or Ent for the CRUD tables and sqlc or hand written SQL for reports.
Checklist
- Compare tools on the same statements, and read the SQL each one sends.
- Look at allocations and generated code size, not only latency.
- Know GORM's transaction default before benchmarking writes.
- Avoid
LIMIT $non a query whose plan depends on it, or inline it. - Pick by team skill and query shape, then measure your own workload.
The libraries are documented at gorm.io, sqlc.dev, entgo.io and the sqlx repository.


