Skip to content
gohacks.dev
Databases

sqlc vs Hand Written database sql Code and the Real Gap

sqlc against hand written database/sql on PostgreSQL: generated code, latency within 5 percent, 11 allocations against 31, and where hand written code wins

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

Hand written database/sql code is the baseline every Go data library argues with, and the argument usually goes one of two ways. Either "you do not need anything, the standard library is enough", or "the standard library is verbose and error prone, use a tool". Both are right about something. This article puts sqlc, which generates database/sql style code from SQL, next to the hand written version of the same four queries on PostgreSQL 18.6, and measures what the generation changes: speed, allocations, lines you maintain and the mistakes the compiler catches.

Tested with Go 1.25.6, sqlc v1.30.0, pgx v5.11.0 (native and stdlib), PostgreSQL 18.6 in Docker, macOS on Apple Silicon (arm64)

It builds on the sqlc tutorial and database/sql explained. The migration between the two styles is covered in sqlc with database/sql, and the wider tool comparison in GORM vs sqlc vs Ent vs sqlx.

What is being compared

Four operations on accounts and orders: an insert that returns an id, a lookup by unique email, a list of 50 orders for one account, and an account joined with its 50 orders. The hand written version uses database/sql over pgx's stdlib adapter. The sqlc version is the generated package over a pgxpool.

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
}
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()
}

That is 67 lines for four queries, and most of it is the same shape repeated: run the query, scan into locals, check the error, loop over rows, check rows.Err(). It is not hard code. It is code where a forgotten defer rows.Close() or a scan with the columns in the wrong order compiles and fails later.

The sqlc side starts with the same SQL in a file, plus a configuration:

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);
query.sqlText
-- 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;
sqlc.yamlText
version: "2"
sql:
  - engine: postgresql
    schema: schema.sql
    queries: query.sql
    gen:
      go:
        package: db
        out: db
        sql_package: pgx/v5

and ends with the adapter that implements the benchmark's interface over the generated methods:

impl_sqlc.goGo
package 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
}
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},
	{"sqlc", newSQLC},
}

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/bin/sqlc generate
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  	    5293	    442159 ns/op	     872 B/op	      19 allocs/op
BenchmarkCreate/sqlc          	    4628	    438401 ns/op	     508 B/op	      11 allocs/op
BenchmarkGetByEmail/database/sql         	   10719	    210339 ns/op	    1184 B/op	      31 allocs/op
BenchmarkGetByEmail/sqlc                 	   10000	    221832 ns/op	     692 B/op	      11 allocs/op
BenchmarkListOrders/database/sql         	    2800	    983298 ns/op	    5392 B/op	     416 allocs/op
BenchmarkListOrders/sqlc                 	    2533	    965208 ns/op	   12156 B/op	     162 allocs/op
BenchmarkAccountWithOrders/database/sql  	    7578	    322107 ns/op	    5360 B/op	     416 allocs/op
BenchmarkAccountWithOrders/sqlc          	    7227	    301402 ns/op	   12291 B/op	     162 allocs/op
ok  	example.test/lab	23.970s

What sqlc wrote

The generated query.sql.go is 114 lines for the four queries; with db.go (the DBTX interface and New) and models.go (one struct per table), the package is 169 lines. A representative method:

Go
func (q *Queries) AccountByEmail(ctx context.Context, email string) (Account, error) {
	row := q.db.QueryRow(ctx, accountByEmail, email)
	var i Account
	err := row.Scan(
		&i.ID,
		&i.Email,
		&i.Name,
		&i.CreatedAt,
	)
	return i, err
}

That is the function you would write. The SQL lives in a constant, the parameters are typed (email string), the result is a named struct, and the columns scanned are the columns selected, in order, because the tool read them from the query. For the list query the generated code opens the rows, defers Close, scans into a []Order and checks rows.Err(), as a careful developer would, every time. The value of generation is that "every time" is guaranteed.

Parameter and row types come from the schema. The join returns an AccountWithOrdersRow with an int32 for total_cents because the column is a PostgreSQL integer. If someone changes the column to bigint in a migration, the next generation changes the field to int64 and the compiler flags every caller that assumed 32 bits.

Speed

Operation (µs)database/sqlsqlc
Insert442438
Lookup by email210222
List 50 orders983965
Account with 50 orders322301

The two are within about 5 percent in both directions, which is run to run noise on one machine. The generated code adds no layer. It calls the pgx pool, scans, and returns. A claim that sqlc costs anything measurable over hand written code is wrong, and a claim that it is faster than hand written code is also wrong in any way that matters: what differs is the driver path, below.

Allocations

Allocations per calldatabase/sqlsqlc
Insert1911
Lookup3111
List 50416162
Account with 50 orders416162

sqlc made fewer allocations, by a factor of two to three. The reason is not the generated code. It is the driver interface the code uses. database/sql converts each column to a driver.Value and then to the destination type; the generated pgx code scans straight into the typed destination. A hand written program that used pgx natively would likely land near sqlc's numbers, which I did not measure. The lab compares against the stdlib path because that is what most hand written Go code uses.

A caution about these numbers: the hand written baseline here counts rows and discards them, and the sqlc version returns a slice of structs. That is why sqlc allocated more bytes for the 50 row list (12156 B against 5392 B) while making fewer allocations: it kept the result. Reading the table as "sqlc does the same work with fewer allocations" would be too generous. Reading it as "the driver path and the generated scan code are cheap" is right.

Mistakes the compiler catches

What generation buys is correctness work moved from run time to build time, and the comparison to hand written code is clearest there:

  • A column name typo in hand written SQL is a run time error on the first call. In sqlc it fails generate with the file, line and column of the typo (the same experiment, with a rename).
  • A scan order mismatch (Scan(&name, &email) for select email, name) type checks if both are strings and swaps the data silently. The generated scan follows the select list.
  • A wrong parameter count or type compiles in hand written code (QueryRow(q, a, b) for one placeholder) and fails when it runs. The generated method has typed parameters and the right number.
  • A forgotten rows.Close() or rows.Err() leaks a connection or hides a mid stream error. Generated code does both.
  • A schema change that drops a column used by a query fails the next generation. Hand written SQL fails in production.

Hand written code can reach the same safety with a test for every query against a real database. Generation makes the check part of the build and removes the need to remember it.

Where hand written code is better

  • Dynamic SQL. A search with optional filters, a variable IN list, a column chosen by the user. Generated code is a fixed set of statements. sqlc has patterns for optional filters (dynamic queries), and a statement assembled at run time is still easier in hand written code or with a builder (dynamic SQL builders).
  • A handful of queries. For three queries, the tool, the config, the generation step and the CI check cost more than 30 lines of scanning code.
  • Unusual types and drivers. Where the schema uses types sqlc does not map, or you need a driver feature the generator does not expose, hand written code gives full control. Overrides cover many cases (sqlc overrides).
  • Anything that is not SQL the generator can parse. Rare extensions and exotic syntax can trip the parser. Check your schema against the tool in an hour, before you commit to it.

A middle path

Keep both. A service often has 40 ordinary queries that sqlc handles well and 3 dynamic ones that do not. Put the 40 in query.sql, write the 3 by hand over the same DBTX (the generated Queries accepts the pool or a transaction, so hand written code can use the same connection and the same transaction). The two live together without friction, which is also what makes adopting sqlc an incremental step.

Checklist

  • Do not expect speed from generation. Expect fewer mistakes and less boilerplate.
  • Use the pgx native interface, not the stdlib adapter, if allocations matter.
  • Compare like with like: a baseline that discards rows is not doing the same work.
  • Keep dynamic queries hand written or built, and everything else generated.
  • Run generation and a diff check in CI so the generated code never drifts from the SQL.

The tool is documented at docs.sqlc.dev, and the driver interface at pkg.go.dev for database/sql.

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.