Go Database Libraries Compared sqlx pgx GORM sqlc and ent
Compare Go database libraries on one benchmark: pgx, database/sql, sqlx, sqlc, GORM and bun for get, list, insert and update, with the numbers and a verdict.

The Go standard library gives you database/sql, a small interface that every driver implements. Around it grew a family of libraries that each move the line between "you write SQL" and "the library writes SQL". Choosing among them is a decision you live with for years, and it is usually made on taste and folklore. This roundup measures the libraries on the same four operations against PostgreSQL 18.6 on Go 1.25.6, and puts the numbers next to what each library does for you, so the choice can rest on something. The quick verdict is near the end.
Tested with Go 1.25.6, pgx v5.11.0, sqlx v1.4.0, sqlc v1.30.0, GORM v1.31.2, bun v1.3.0, PostgreSQL 18.6 in Docker, macOS on Apple Silicon (arm64). ent was not run.
It belongs to the database cornerstone. The question of whether to use an ORM at all is ORM or raw SQL, and the ranking of the ORMs is the best Go ORMs.
The libraries
| Library | What it is | You write |
|---|---|---|
pgx (native) | A PostgreSQL driver and toolkit with its own API, pooling and types | SQL, and the scanning code |
database/sql | The standard interface, with a driver such as pgx's stdlib | SQL, and the scanning code |
sqlx | Extensions to database/sql: scan into structs, named parameters | SQL |
sqlc | Generates typed Go from SQL files at build time | SQL files; the Go is generated |
| GORM | A full ORM: models, associations, migrations, hooks, query builder | Go models and method calls |
bun | A SQL first ORM and query builder over database/sql | Go models and a fluent builder |
ent | An ORM that generates a typed API and a graph of entities from Go schemas | Go schemas; the API is generated |
The benchmark
package main
import (
"context"
"database/sql"
"fmt"
"log"
"math/rand/v2"
"time"
"github.com/jackc/pgx/v5/pgxpool"
_ "github.com/jackc/pgx/v5/stdlib"
"github.com/jmoiron/sqlx"
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect/pgdialect"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
const dsn = "postgres://postgres:[email protected]:55432/lab?sslmode=disable"
func must(err error) {
if err != nil {
log.Fatal(err)
}
}
type Row struct {
ID int64 `db:"id" gorm:"primaryKey" bun:"id,pk,autoincrement"`
Name string `db:"name" bun:"name"`
Email string `db:"email" bun:"email"`
Age int `db:"age" bun:"age"`
}
type gormUser struct{ Row }
func (gormUser) TableName() string { return "users650" }
type bunUser struct {
bun.BaseModel `bun:"table:users650"`
Row
}
type lib struct {
name string
get func(id int64)
list func()
insert func(i int)
update func(id int64, age int)
}
const (
nGet, nList, nInsert, nUpdate = 4000, 400, 1500, 1500
rows = 50000
)
func main() {
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
must(err)
defer pool.Close()
for _, q := range []string{
"drop table if exists users650",
"create table users650 (id bigserial primary key, name text not null, email text not null, age int not null)",
fmt.Sprintf("insert into users650 (name, email, age) select 'user ' || i, 'user' || i || '@example.test', i %% 90 from generate_series(1, %d) i", rows),
"analyze users650",
} {
_, err := pool.Exec(ctx, q)
must(err)
}
std, err := sql.Open("pgx", dsn)
must(err)
std.SetMaxOpenConns(4)
sx := sqlx.NewDb(std, "pgx")
gdb, err := gorm.Open(postgres.New(postgres.Config{Conn: std}), &gorm.Config{Logger: logger.Discard})
must(err)
gdb2, err := gorm.Open(postgres.New(postgres.Config{Conn: std}), &gorm.Config{Logger: logger.Discard, SkipDefaultTransaction: true})
must(err)
bdb := bun.NewDB(std, pgdialect.New())
q := New(pool)
libs := []lib{
{"pgx", func(id int64) {
var u Row
must(pool.QueryRow(ctx, "select id, name, email, age from users650 where id = $1", id).Scan(&u.ID, &u.Name, &u.Email, &u.Age))
}, func() {
rs, err := pool.Query(ctx, "select id, name, email, age from users650 order by id limit 100")
must(err)
for rs.Next() {
var u Row
must(rs.Scan(&u.ID, &u.Name, &u.Email, &u.Age))
}
must(rs.Err())
}, func(i int) {
_, err := pool.Exec(ctx, "insert into users650 (name, email, age) values ($1, $2, $3)", "n", "e", i%90)
must(err)
}, func(id int64, age int) {
_, err := pool.Exec(ctx, "update users650 set age = $2 where id = $1", id, age)
must(err)
}},
{"database/sql", func(id int64) {
var u Row
must(std.QueryRowContext(ctx, "select id, name, email, age from users650 where id = $1", id).Scan(&u.ID, &u.Name, &u.Email, &u.Age))
}, func() {
rs, err := std.QueryContext(ctx, "select id, name, email, age from users650 order by id limit 100")
must(err)
defer rs.Close()
for rs.Next() {
var u Row
must(rs.Scan(&u.ID, &u.Name, &u.Email, &u.Age))
}
must(rs.Err())
}, func(i int) {
_, err := std.ExecContext(ctx, "insert into users650 (name, email, age) values ($1, $2, $3)", "n", "e", i%90)
must(err)
}, func(id int64, age int) {
_, err := std.ExecContext(ctx, "update users650 set age = $2 where id = $1", id, age)
must(err)
}},
{"sqlx", func(id int64) {
var u Row
must(sx.GetContext(ctx, &u, "select id, name, email, age from users650 where id = $1", id))
}, func() {
var us []Row
must(sx.SelectContext(ctx, &us, "select id, name, email, age from users650 order by id limit 100"))
}, func(i int) {
_, err := sx.ExecContext(ctx, "insert into users650 (name, email, age) values ($1, $2, $3)", "n", "e", i%90)
must(err)
}, func(id int64, age int) {
_, err := sx.ExecContext(ctx, "update users650 set age = $2 where id = $1", id, age)
must(err)
}},
{"sqlc (pgx)", func(id int64) {
_, err := q.GetUser(ctx, id)
must(err)
}, func() {
_, err := q.ListUsers(ctx)
must(err)
}, func(i int) {
must(q.InsertUser(ctx, InsertUserParams{Name: "n", Email: "e", Age: int32(i % 90)}))
}, func(id int64, age int) {
must(q.SetAge(ctx, SetAgeParams{ID: id, Age: int32(age)}))
}},
{"GORM (default)", func(id int64) {
var u gormUser
must(gdb.WithContext(ctx).First(&u, id).Error)
}, func() {
var us []gormUser
must(gdb.WithContext(ctx).Order("id").Limit(100).Find(&us).Error)
}, func(i int) {
u := gormUser{Row{Name: "n", Email: "e", Age: i % 90}}
must(gdb.WithContext(ctx).Create(&u).Error)
}, func(id int64, age int) {
must(gdb.WithContext(ctx).Model(&gormUser{}).Where("id = ?", id).Update("age", age).Error)
}},
{"GORM, no tx", func(id int64) {
var u gormUser
must(gdb2.WithContext(ctx).First(&u, id).Error)
}, func() {
var us []gormUser
must(gdb2.WithContext(ctx).Order("id").Limit(100).Find(&us).Error)
}, func(i int) {
u := gormUser{Row{Name: "n", Email: "e", Age: i % 90}}
must(gdb2.WithContext(ctx).Create(&u).Error)
}, func(id int64, age int) {
must(gdb2.WithContext(ctx).Model(&gormUser{}).Where("id = ?", id).Update("age", age).Error)
}},
{"bun", func(id int64) {
var u bunUser
must(bdb.NewSelect().Model(&u).Where("id = ?", id).Scan(ctx))
}, func() {
var us []bunUser
must(bdb.NewSelect().Model(&us).Order("id").Limit(100).Scan(ctx))
}, func(i int) {
u := bunUser{Row: Row{Name: "n", Email: "e", Age: i % 90}}
_, err := bdb.NewInsert().Model(&u).Exec(ctx)
must(err)
}, func(id int64, age int) {
_, err := bdb.NewUpdate().Model((*bunUser)(nil)).Set("age = ?", age).Where("id = ?", id).Exec(ctx)
must(err)
}},
}
// Every library does the same work in the same order; the run is repeated and the best of three is kept.
timeIt := func(n int, fn func(i int)) time.Duration {
best := time.Duration(1 << 62)
for r := 0; r < 3; r++ {
start := time.Now()
for i := 0; i < n; i++ {
fn(i)
}
if d := time.Since(start); d < best {
best = d
}
}
return best / time.Duration(n)
}
fmt.Printf("%d rows, one connection of work at a time, microseconds per operation (best of 3)\n", rows)
fmt.Printf("%-14s %10s %12s %10s %10s\n", "library", "get by id", "list 100", "insert", "update")
r := rand.New(rand.NewPCG(3, 4))
ids := make([]int64, nGet)
for i := range ids {
ids[i] = int64(r.IntN(rows) + 1)
}
for _, l := range libs {
get := timeIt(nGet, func(i int) { l.get(ids[i]) })
list := timeIt(nList, func(int) { l.list() })
ins := timeIt(nInsert, func(i int) { l.insert(i) })
upd := timeIt(nUpdate, func(i int) { l.update(ids[i], i%90) })
fmt.Printf("%-14s %10d %12d %10d %10d\n", l.name, get.Microseconds(), list.Microseconds(), ins.Microseconds(), upd.Microseconds())
}
}The program fills a table of 50,000 users, then runs four operations through each library with the same SQL semantics: get one row by random id, list 100 rows ordered by id, insert one row, and update one column of one row by id. Each operation runs thousands of times and the best of three passes is kept. The sqlc code is generated by running sqlc generate in the lab, from the files below, into the same package:
-- name: GetUser :one
select id, name, email, age from users650 where id = $1;
-- name: ListUsers :many
select id, name, email, age from users650 order by id limit 100;
-- name: InsertUser :exec
insert into users650 (name, email, age) values ($1, $2, $3);
-- name: SetAge :exec
update users650 set age = $2 where id = $1;create table users650 (
id bigserial primary key,
name text not null,
email text not null,
age int not null
);version: "2"
sql:
- engine: postgresql
schema: schema.sql
queries: query.sql
gen:
go:
package: main
out: .
sql_package: pgx/v5set -e
"$HOME/go/bin/sqlc" generate
go run .50000 rows, one connection of work at a time, microseconds per operation (best of 3)
library get by id list 100 insert update
pgx 208 238 351 388
database/sql 208 250 391 465
sqlx 218 254 333 434
sqlc (pgx) 206 245 392 418
GORM (default) 220 306 692 851
GORM, no tx 229 281 362 403
bun 596 303 381 408The numbers are microseconds per operation, from one goroutine, on a laptop with the database in a container on the same machine:
| Library | Get by id | List 100 | Insert | Update |
|---|---|---|---|---|
| pgx | 208 | 238 | 351 | 388 |
| database/sql | 208 | 250 | 391 | 465 |
| sqlx | 218 | 254 | 333 | 434 |
| sqlc (pgx) | 206 | 245 | 392 | 418 |
| GORM, default | 220 | 306 | 692 | 851 |
| GORM, no default transaction | 229 | 281 | 362 | 403 |
| bun | 596 | 303 | 381 | 408 |
Reading the table
The hand written libraries are tied. pgx, database/sql, sqlx and sqlc finish within noise of each other: 206 to 218 microseconds for a get, 238 to 254 for a list. Run to run, they swap places. All of that time is a local network round trip and the server's work; the library's overhead is a few microseconds in 200. If someone tells you one of these is "faster" for ordinary queries, they are describing noise or a different workload, such as very high concurrency or large result sets, where pgx's binary protocol and lower allocation do matter.
GORM's cost is one default, not the ORM. GORM wraps each write in a transaction unless told not to, which adds a BEGIN and a COMMIT round trip: inserts took 692µs and updates 851µs by default, about double. With SkipDefaultTransaction: true, they took 362µs and 403µs, level with the others. Reads cost 5 to 20 percent more (306 against 250 for the list) from reflection and the generality of the builder. For most services that is not a reason to avoid it, and the transaction default is a setting worth knowing. It is there for safety around hooks that write several rows, and a service that does not use them loses nothing by turning it off.
bun's get by id was an outlier: 596µs against about 210 for the rest, while its list, insert and update were normal. I did not investigate the cause. A likely one is that bun builds queries by putting argument values into the SQL text on the client rather than sending them as bind parameters, which changes how the server handles the statement and prevents the extra round trips of the extended protocol from being amortised; check the library's behaviour and your own measurements before relying on that guess. The lesson is general: benchmark the paths you use, since a library can be quick on average and slow on one call.
What the numbers do not show
Memory and allocations. The lab times operations and does not count garbage. pgx and sqlc allocate least; reflection based libraries allocate more per row, and at high request rates that becomes GC pressure and latency. Use -benchmem and a real profile if it matters.
Concurrency. One goroutine measures latency and not throughput. Under load, pooling decides: pgx's own pool has finer control than database/sql's, and database/sql based libraries share its limits.
Large results. Reading a million rows favours pgx and its streaming API (CollectRows, RowToStructByName) over libraries that load everything into slices.
Correctness, not speed, is the main difference. The libraries differ most in what mistakes they let you make, which no benchmark shows. sqlc catches a wrong column name or type at generation. sqlx and database/sql catch it at run time. GORM and bun catch some at compile time through Go types and some at run time through strings.
Choosing
pgx, native, when you use PostgreSQL only and want the most capable driver: pooling, COPY, batches, listen and notify, custom types, tracing. You write the scanning code or use its helpers. It is the default for a PostgreSQL service in 2026. See pgx explained.
database/sql with a driver, when you need portability across databases or must work with other libraries that take a *sql.DB. You give up some pgx features and gain a standard interface. See how database/sql works.
sqlx, when you like writing SQL by hand and want only the scanning boilerplate gone: Get, Select, named parameters. A small, stable library on top of the standard one. See sqlx in Go.
sqlc, when you want SQL as the source of truth with compile time safety: queries live in .sql files, and Go code is generated. It suits teams that read SQL, and it is a strong default for new PostgreSQL services. The cost is a build step and a limit on dynamic queries. See sqlc from install to type safe queries.
GORM, when speed of development on CRUD heavy applications matters more than control, and the team is happy to learn its rules (zero values, associations, hooks). The largest ecosystem and documentation of the ORMs. Turn off the default transaction if you measure it.
bun, when you want an ORM that stays close to SQL, with a builder that reads like the query it makes. Smaller than GORM and a good fit for PostgreSQL features. Measure the paths you use.
ent, when your domain is a graph of related entities and you value a generated, fully typed API with traversal and a schema defined in Go. It was not run here: it generates a package per schema, which does not fit a single file lab, so this description comes from its documentation. Expect a code generation step and a model that you define in Go before you get queries.
A sensible default
For a new PostgreSQL service: pgx with sqlc for the queries, a migration tool such as goose, and database/sql only where a dependency needs it. For a fast moving CRUD application with a small team: GORM or bun, with statement logging on in development. For a CLI or a small tool: database/sql and SQLite or PostgreSQL with a few queries, no extra library. In every case, measure your own queries: the table above says the libraries are close, and your workload may differ. The drivers and tools are documented at pgx, sqlc, GORM and ent, and the benchmark's source is the lab above.


