GORM vs database sql and When the ORM Starts to Cost You
Measure what GORM adds over database/sql on PostgreSQL: latency, allocations and bytes per call, then work out the load at which the overhead starts to matter

The question "is GORM slow?" has a boring answer: it adds a fixed amount of work to each call, and whether that matters depends on how many calls you make and how much else they cost. This article measures that fixed amount against plain database/sql on PostgreSQL 18.6, using the same driver for both so the difference is the library and not the connection. It then turns the measurements into a rule for when you would notice.
Tested with Go 1.25.6, GORM v1.31.2, gorm.io/driver/postgres v1.6.3, pgx v5.11.0 (stdlib), PostgreSQL 18.6 in Docker, macOS on Apple Silicon (arm64)
It sits beside the GORM tutorial, GORM performance mistakes (which lists the avoidable costs) and GORM vs sqlc vs Ent vs sqlx, which adds the other tools. For the plain standard library layer, see database/sql explained.
The experiment
The same four operations behind one interface: an insert returning an id, a lookup by unique email, a list of 50 orders for an account, and an account with its 50 orders. Both implementations use pgx; database/sql goes through pgx's stdlib adapter and GORM through its PostgreSQL driver, which wraps the same.
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);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()
}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 lab
import (
"context"
"fmt"
"sync/atomic"
"testing"
)
var impls = []struct {
name string
make func(schema string) (Store, error)
}{
{"database/sql", newSQL},
{"gorm", newGORM},
}
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 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 5112 422974 ns/op 874 B/op 19 allocs/op
BenchmarkCreate/gorm 3018 839392 ns/op 7365 B/op 93 allocs/op
BenchmarkGetByEmail/database/sql 9627 212872 ns/op 1184 B/op 31 allocs/op
BenchmarkGetByEmail/gorm 10000 217857 ns/op 4985 B/op 70 allocs/op
BenchmarkListOrders/database/sql 2887 978201 ns/op 5392 B/op 416 allocs/op
BenchmarkListOrders/gorm 2433 1054890 ns/op 19839 B/op 614 allocs/op
BenchmarkAccountWithOrders/database/sql 7003 308926 ns/op 5360 B/op 416 allocs/op
BenchmarkAccountWithOrders/gorm 3879 550346 ns/op 32662 B/op 814 allocs/op
ok example.test/lab 20.095sWhat GORM adds per call
| Operation | database/sql µs | GORM µs | Extra allocations | Extra bytes |
|---|---|---|---|---|
| Insert | 423 | 839 | +74 | +6491 |
| Lookup by email | 213 | 218 | +39 | +3801 |
| List 50 orders | 978 | 1055 | +198 | +14447 |
| Account with 50 orders | 309 | 550 | +398 | +27302 |
(The extra columns are GORM minus database/sql: for the insert, 93 against 19 allocations and 7365 against 874 bytes; for the lookup, 70 against 31 and 4985 against 1184; for the list, 614 against 416 and 19839 against 5392; for the join, 814 against 416 and 32662 against 5360.)
Three observations.
Reads cost memory more than time. A lookup was 5 µs slower, which is noise on this machine, yet GORM made 39 more allocations and 3.8 KB more garbage. The time shows up as CPU in your service, not as latency in the database call. A single call never notices. A service doing a few thousand of these a second spends a visible share of its CPU on reflection and garbage collection.
The overhead scales with rows and structure. The list of 50 rows added 198 allocations, about 4 per row. The account with orders added 398 because GORM's Preload runs two queries and assembles the parent and children through reflection, where the join in database/sql is one query and one loop. Rows times fields is the multiplier. A page that loads 20 rows is cheap. A job that loads a million is not.
The insert is a default, not a limit. At 839 µs against 423, GORM looks twice as slow at writes, and nearly all of that is the implicit transaction: BEGIN, the insert and COMMIT are three round trips where database/sql makes one. Skipping the default transaction closes most of the gap (measured in the performance article and batch inserts). A fair comparison turns it off or wraps the baseline in a transaction.
From per call to per second
A measurement per call is only useful converted into load. Take the heaviest read here, the account with orders: GORM allocates about 27 KB more than the join.
- At 100 calls a second, that is 2.7 MB a second of extra garbage. Invisible.
- At 1000 calls a second, 27 MB a second. The garbage collector can start to show in a CPU profile.
- At 10000 calls a second, 270 MB a second, and the collector's work and the heap target both matter. This is where services start to talk about GC tuning (GOGC) and fewer allocations.
The arithmetic is simple, and the useful question follows from it: how many calls per second does this code path make, and how much of the service's CPU is it? A back office tool at 20 requests a second has no problem. An API handling 5000 requests a second, each doing three ORM calls, is spending real cores on mapping. Profile before deciding (CPU profiles with pprof and allocation profiles): the profile shows the exact share and which calls to move.
When the ORM starts to cost
- Hot read paths. The few endpoints that carry most of the traffic. Rewrite those with
RawplusScan(still GORM, much less reflection) or withdatabase/sql. - Bulk loads. Reports, exports and migrations that read hundreds of thousands of rows. Stream with
Rows()andScanRows, or leave the ORM. - Write heavy paths. Turn off the default transaction, use
CreateInBatches, and prefer one statement over loops. - Tiny latency budgets. When the database is on the same host or behind a pooler and the round trip is 100 µs, a library overhead of tens of microseconds is a visible fraction. Over a network with a 1 ms round trip, it disappears.
- Memory limited environments. Containers with a small memory limit feel the garbage first; see GOMEMLIMIT.
When it does not
Most services. An endpoint that does one lookup and one write, at 50 requests a second, spends its time in network, serialization and the database. The ORM's overhead is a small fraction of a percent. The cost that does matter in that service is the one the benchmark cannot show: the N plus one query (Preload and Joins), the missing index, and the transaction left open. Fix those first. They are worth orders of magnitude more than the allocations above.
What you get for the overhead
Be fair about the other side of the trade. The database/sql code in this lab is 67 lines for four operations and each is a place to forget rows.Close() or mismatch a scan. GORM's version is 56 lines, handles NULLs and relations, builds the SQL for you and gives you hooks, scopes and migrations. For a CRUD service, that time saved is real, and the overhead is cheap insurance. The honest framing is that GORM sells convenience and charges a per call fee, and you should check whether you are in the range where the fee is noticeable.
A migration path when it starts to cost
You do not have to choose one. GORM exposes the underlying pool with db.DB(), so a hot query can run as plain database/sql on the same connection pool, and as shown in migrating from GORM to sqlc, even inside the same transaction. Move the three hottest queries out first, measure, and stop when the profile stops justifying more work.
Checklist
- Compare with the same driver and the default transaction accounted for.
- Convert the per call numbers into calls per second for your real endpoints.
- Profile first, then move only the hot paths.
- Fix N plus one queries and missing indexes before worrying about allocations.
- Keep both in one program when it helps: they share the pool.
The libraries are documented at gorm.io and in the database/sql package.


