Skip to content
gohacks.dev
Databases

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

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

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:

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
}
  • CreateAccount: one insert returning the id.
  • AccountByEmail: a lookup on a unique column.
  • ListOrders: 50 rows of one account, ordered by id, with a LIMIT.
  • 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:

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);

The five implementations

Plain database/sql, the baseline everyone else is compared to:

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

sqlx, which keeps your SQL and removes the scanning boilerplate:

impl_sqlx.goGo
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:

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
}

sqlc, from SQL files to generated methods:

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

Ent, from a schema in Go:

ent/schema/schema.goGo
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")}
}
ent/generate.goGo
package ent

//go:generate go run -mod=mod entgo.io/ent/cmd/ent generate --feature sql/upsert ./schema
impl_ent.goGo
package 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

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},
	{"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)
			}
		}
	})
}
Shell
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]+ / /"
Text
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.594s

Results

Operationdatabase/sqlsqlxsqlcGORMEnt
Create (µs)419431425866446
Lookup by email (µs)220217219215230
List 50 orders (µs)1112107410131031323
Account with orders (µs)304338308550517
Allocations per calldatabase/sqlsqlxsqlcGORMEnt
Create1923119371
Lookup3133117098
List 50416378162614607
With orders416378162814700

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:

Shell
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);
SQL
Text
SET
                     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 linesGenerated lines
database/sql670
sqlx580
GORM560
sqlc37 + 13 SQL + 10 config169
Ent52 + 40 schema7331

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, Save writes 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 Scan calls. A small step up from database/sql, with the allocation cost of reflection.
  • database/sql with 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 $n on 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.

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.