Skip to content
gohacks.dev
Databases

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

10 min read
Summarize in AI
Flat illustration of two options weighed side by side.

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.

store.goGo
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
}
schema.sqlText
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);
impl_sql.goGo
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()
}
impl_gorm.goGo
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
}
bench_test.goGo
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)
			}
		}
	})
}
Shell
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]+ / /"
Text
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.095s

What GORM adds per call

Operationdatabase/sql µsGORM µsExtra allocationsExtra bytes
Insert423839+74+6491
Lookup by email213218+39+3801
List 50 orders9781055+198+14447
Account with 50 orders309550+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 Raw plus Scan (still GORM, much less reflection) or with database/sql.
  • Bulk loads. Reports, exports and migrations that read hundreds of thousands of rows. Stream with Rows() and ScanRows, 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.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.