Skip to content
gohacks.dev
Databases

sqlc vs GORM and Ent for a Brand New Go Service Project

Choosing sqlc, GORM or Ent for a new Go service: a column rename shows when each tool tells you, plus benchmarks and a decision guide by team and schema

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

You are starting a Go service with a PostgreSQL database and have to pick how the code talks to it. The three names that come up are sqlc, GORM and Ent. The comparison articles usually benchmark them, and the benchmark rarely decides anything, because on a simple query they are within noise of each other. What differs is when the tool tells you that you made a mistake, how much code you write and read, and what the project looks like in a year. This article measures those, using one schema and the same operations for each, and ends with a guide by situation.

Tested with Go 1.25.6, sqlc v1.30.0, GORM v1.31.2, Ent v0.14.5, pgx v5.11.0, PostgreSQL 18.6 in Docker, macOS on Apple Silicon (arm64)

It is one of a set of comparisons: GORM vs sqlc vs Ent vs sqlx adds sqlx and database/sql, Ent vs GORM and Ent vs sqlc take the pairs, and the database cornerstone places them among the other options. Tutorials: sqlc, GORM, Ent.

The three, in one paragraph each

sqlc reads your SQL schema and your SQL queries and generates Go: a struct per table and a method per query. There is no runtime library to speak of. You write every query yourself, in SQL, and the compiler and the generator check them.

GORM maps structs to tables and builds SQL from method chains at run time. You describe models and relations in Go, call Create, Where, Preload, and GORM produces the SQL. Fast to start, flexible, and the most habit forming for people coming from other ORMs.

Ent is a code generator driven by a schema written in Go. You declare fields, edges and indexes, run the generator, and get a typed client: builders, predicates and eager loading for a graph of entities. Heavier to learn, with a large generated package and strong typing.

The setup for the numbers

The lab runs four operations (insert, lookup by email, list 50 orders, an account with its 50 orders) against the same accounts and orders tables, with each tool behind one Store interface. This version keeps only the three tools in question; the others are in the larger comparison.

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);
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
}
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
}
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
}
bench_test.goGo
package lab

import (
	"context"
	"fmt"
	"sync/atomic"
	"testing"
)

var impls = []struct {
	name string
	make func(schema string) (Store, error)
}{
	{"gorm", newGORM},
	{"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
go: found github.com/rogpeppe/go-internal/fmtsort in github.com/rogpeppe/go-internal v1.16.0
BenchmarkCreate/gorm          	    2494	    833155 ns/op	    7350 B/op	      93 allocs/op
BenchmarkCreate/sqlc          	    5145	    418552 ns/op	     509 B/op	      11 allocs/op
BenchmarkCreate/ent           	    5949	    413737 ns/op	    2794 B/op	      71 allocs/op
BenchmarkGetByEmail/gorm      	    9398	    221500 ns/op	    4987 B/op	      70 allocs/op
BenchmarkGetByEmail/sqlc      	   10000	    225378 ns/op	     691 B/op	      11 allocs/op
BenchmarkGetByEmail/ent       	   10000	    228674 ns/op	    4304 B/op	      98 allocs/op
BenchmarkListOrders/gorm      	    2696	   1138552 ns/op	   19854 B/op	     614 allocs/op
BenchmarkListOrders/sqlc      	    2793	    946256 ns/op	   12156 B/op	     162 allocs/op
BenchmarkListOrders/ent       	    6483	    320118 ns/op	   19552 B/op	     607 allocs/op
BenchmarkAccountWithOrders/gorm         	    4008	    607949 ns/op	   32677 B/op	     814 allocs/op
BenchmarkAccountWithOrders/sqlc         	    8076	    327218 ns/op	   12292 B/op	     162 allocs/op
BenchmarkAccountWithOrders/ent          	    3718	    594867 ns/op	   24672 B/op	     700 allocs/op
ok  	example.test/lab	32.188s

When does the tool tell you?

This is the question that decides a codebase's maintenance cost. Real services change schemas constantly, and the cost of a change is the time between making it and finding every query it broke. The lab renames the email column to email_address in a copy of the project and sees where each tool notices:

Shell
# Rename the email column in a copy and see where each tool notices.
rm -rf rename && mkdir rename
cp -r *.go *.sql *.yaml go.mod go.sum ent db rename/
cd rename
perl -pi -e 's/\bemail\b(\s+text not null unique)/email_address$1/' schema.sql

echo "== GORM: the struct still says email, the schema says email_address"
go test -run='^$' -bench='GetByEmail/gorm$' -benchtime=1x . 2>&1 | grep -E 'does not exist|^FAIL|^ok' | head -2

echo "== sqlc: generate reads the new schema"
~/go/bin/sqlc generate 2>&1 | head -2

echo "== Ent: rename the field in the schema, regenerate, build"
perl -pi -e 's/field\.String\("email"\)/field.String("email_address")/' ent/schema/schema.go
GOFLAGS=-mod=mod go generate ./ent > /dev/null 2>&1
go build . 2>&1 | head -3
cd .. && rm -rf rename
Text
== GORM: the struct still says email, the schema says email_address
    bench_test.go:24: ERROR: column "email" of relation "accounts" does not exist (SQLSTATE 42703)
FAIL
== sqlc: generate reads the new schema
# package db
query.sql:2:23: column "email" does not exist
== Ent: rename the field in the schema, regenerate, build
# example.test/lab
./impl_ent.go:26:33: s.c.Account.Create().SetEmail undefined (type *"example.test/lab/ent".AccountCreate has no field or method SetEmail)
./impl_ent.go:34:46: undefined: account.Email
  • GORM compiled without complaint. The struct field Email still maps to a column named email, which no longer exists, and the first query that ran failed with ERROR: column "email" of relation "accounts" does not exist (SQLSTATE 42703). The error is clear. It arrives at run time, in a test if you have one that executes the query, in production if you do not. The mapping between struct and table is a string in a tag or a naming convention, and the compiler cannot see it.
  • sqlc failed at generation: query.sql:2:23: column "email" does not exist. The line and column point at the word. No Go was compiled and no database was needed, because sqlc checks the queries against the schema file.
  • Ent regenerated its client from the changed schema, and then go build failed in the code that used the old name: SetEmail undefined and undefined: account.Email. The compiler lists every call site, which is the experience of renaming a Go function.

This is the strongest argument for the generators in a service that will live for years. GORM can reach the same safety with tests that run every query against a real database (GORM testing), and then the check costs a test suite, not a compiler. If your team does not have that discipline yet, the generators build it in.

What the benchmark shows

OperationsqlcGORMEnt
Insert (µs)419833414
Lookup by email (µs)225222229
List 50 orders (µs)9461139320
Account with 50 orders (µs)327608595
Allocations, lookup117098
Allocations, list of 50162614607

A lookup is 222 to 229 µs for all three, which is the PostgreSQL round trip. The insert gap is GORM's default transaction (BEGIN, INSERT, COMMIT), a setting to know about, not a limit of the library (performance mistakes). The list gap in Ent's favour is not Ent being fast: it writes LIMIT 50 into the SQL while the others bind it as a parameter, and PostgreSQL planned the parameterised version badly. The larger comparison shows the plan, and the point holds here too: a benchmark can measure the statement and not the tool.

The numbers that do belong to the tools are allocations. sqlc made 11 for a lookup and 162 for the list. GORM and Ent made several times as many. That matters on a hot path of a busy service, and not on a back office screen.

Decision guide

Choose sqlc if:

  • The team is comfortable with SQL, and a reviewer reading SQL is a feature.
  • You want the compiler to check queries, and the lowest overhead.
  • The service has many hand tuned queries, joins and reports. A query builder would fight you.
  • You can accept writing each query, including simple ones. Dynamic filters need a pattern (dynamic queries with sqlc).

Choose GORM if:

  • Speed to a working API matters more than the last 10 percent of correctness tooling.
  • The schema is mostly CRUD on many small tables, with simple relations.
  • People already know it, and you will write tests against a real database.
  • You accept learning its defaults: transactions on writes, Save writes every field, zero values skipped in struct updates (the tutorial).

Choose Ent if:

  • The domain is a graph: users, groups, permissions, documents, with many relations traversed in queries.
  • You want typed predicates and eager loading generated for you, and extensions like privacy rules or GraphQL.
  • You are fine with a large generated package committed to the repository and a generation step in CI.

Avoid mixing three. A service with sqlc, GORM and Ent all in one module has three mental models, three migration stories and three ways to be wrong. Mixing two is common and sensible (an ORM for CRUD, sqlc for reports). Mixing three is a sign nobody decided.

The cost of changing your mind

Choosing wrong is survivable when the data access sits behind an interface. Migrating from GORM to sqlc gradually works because both can share one connection pool and even one transaction. From Ent or GORM to the other needs a rewrite of the models, since both own the schema description. Moving to sqlc from anything is the easiest direction, because the schema is plain SQL and the queries are the thing you were going to write anyway.

Whichever you pick, keep migrations as versioned SQL files that the database tooling owns, not as a by product of the ORM. That one decision keeps every other choice reversible.

Checklist

  • Rename a column in a scratch copy and see how each candidate reacts. That test is worth more than a benchmark.
  • Compare allocations and generated code size, not just latency.
  • Decide who owns the schema before choosing the library.
  • Put the data layer behind an interface from day one.
  • Pick one primary tool and at most one companion.

The tools are documented at sqlc.dev, gorm.io and entgo.io.

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.