Skip to content
gohacks.dev
Databases

Ent vs sqlc and Where Each One Fits in a Go Codebase

Ent and sqlc are both generators with opposite starting points. Compare their output size, allocations and join cost on PostgreSQL, and where each one belongs

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

Ent and sqlc are both code generators for Go, and that is where the resemblance ends. Ent starts from a model: you describe entities and relations in Go, and it generates a client that can express almost any query over that graph. sqlc starts from queries: you write SQL, and it generates one typed function for each. They suit different shapes of work, and teams argue about them because both claim the word "type safe". This article puts the two side by side on one PostgreSQL 18.6 schema and measures what each generates and costs.

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

It belongs with GORM vs sqlc vs Ent vs sqlx and Ent vs GORM. The tutorials are Ent and sqlc. For the first project decision, see sqlc vs GORM and Ent.

Two starting points

sqlc: the query is the unit. The source of truth is a schema file and a file of named queries. Each query becomes a method with typed parameters and a typed result. If you want something, you write the SQL for it. There is no query language to learn beyond SQL, and no abstraction between you and the statement.

Ent: the entity is the unit. The source of truth is a schema of fields, edges and indexes. The generator emits builders for creating and updating, predicates for filtering, and edge traversal and eager loading. You do not write SQL; you compose operations, and Ent writes the statements.

The first is a set of questions you chose to ask. The second is a vocabulary for asking questions you have not thought of yet. That one difference decides most of the rest.

The lab

The same four operations as the other comparisons: an insert, a lookup by unique email, a list of 50 orders and an account with its 50 orders.

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
}
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)
}{
	{"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/sqlc          	    5230	    421201 ns/op	     508 B/op	      10 allocs/op
BenchmarkCreate/ent           	    5444	    426318 ns/op	    2794 B/op	      71 allocs/op
BenchmarkGetByEmail/sqlc      	   10000	    217715 ns/op	     691 B/op	      11 allocs/op
BenchmarkGetByEmail/ent       	    9860	    228615 ns/op	    4304 B/op	      98 allocs/op
BenchmarkListOrders/sqlc      	    2901	    976327 ns/op	   12154 B/op	     162 allocs/op
BenchmarkListOrders/ent       	    7353	    310753 ns/op	   19552 B/op	     607 allocs/op
BenchmarkAccountWithOrders/sqlc         	    6781	    301374 ns/op	   12292 B/op	     162 allocs/op
BenchmarkAccountWithOrders/ent          	    4131	    591561 ns/op	   24672 B/op	     700 allocs/op
ok  	example.test/lab	19.973s

The measured difference

sqlcEnt
Insert (µs)421426
Lookup (µs)218229
List 50 (µs)976311
Account with 50 orders (µs)301592
Allocations, insert1071
Allocations, lookup1198
Allocations, list of 50162607
Allocations, with orders162700
Generated lines1697331
Hand written lines (adapter plus schema or SQL)37 + 2352 + 40

Writes and single reads are a draw on time. 421 against 426 µs for the insert, 218 against 229 for the lookup. The round trip dominates. In allocations they differ by a factor of seven to nine, because Ent builds a mutation object, runs hooks and interceptors, and assembles an entity, where the sqlc function scans into a local.

The join is the real difference. The account with its orders took 301 µs in sqlc, where one JOIN returns the rows and the code loops once. Ent's WithOrders() runs two queries and attaches the second result set to the first, which cost 592 µs and 700 allocations. This is the general pattern. Ent's eager loading avoids N plus one, and it still does two round trips where SQL can do one. When a read is a join, writing the join wins, and sqlc is built for that.

The list is a plan, not a tool. Ent's 311 µs against sqlc's 976 is the LIMIT parameter again: Ent inlines LIMIT 50 and sqlc binds $2, and on this data PostgreSQL's generic plan for the bound form is a primary key scan that discards 9757 rows. It is a statement level issue you can reproduce with either tool, and the plans are printed in the four way comparison.

What each tool makes easy

Ent

  • Traversal: account.QueryOrders(), then filter and traverse again, without writing a join.
  • Typed predicates over relations: "accounts that have an order matching X" is one method, HasOrdersWith.
  • Mutations with validation, defaults, hooks and edge handling in one builder.
  • Extensions: privacy rules, GraphQL and OpenAPI generated from the same schema.
  • Migrations computed from the schema.

sqlc

  • Exact SQL: window functions, CTEs, lateral joins, FILTER, COPY, anything PostgreSQL does. If you can write it, sqlc generates for it.
  • Reviewable queries: a pull request shows the SQL that will run, and a DBA can read it.
  • Tiny output and a small runtime footprint.
  • Reports and analytical queries, where the query is the whole point.

What each makes hard

Ent

  • Queries outside the graph model (a report with window functions) need Modify, a raw builder hook or SQL on the side.
  • The generated package is large. Compile times and review noise grow with the schema.
  • Learning the API takes a few days, and the edge and field options are a lot to hold.

sqlc

  • Every query is written by hand, including the dozens of trivial ones. A table with five lookups by different columns is five queries.
  • Dynamic queries (optional filters, user chosen sorts) need a pattern or a hand written exception (dynamic queries).
  • Relationships are not modelled. You decide how to load a parent and its children, and write it.
  • The schema file and the real schema must be kept in step with migrations (sqlc and migrations).

Schema ownership

This is the question underneath. With Ent the Go schema is the source of truth and the database is derived from it; migrations can be generated from the difference. With sqlc the SQL schema (your migrations) is the source of truth and the Go code is derived from it. Pick by who you want to own the schema: the application developers writing Go, or the SQL that a database person can reason about. Mixing the two directions in one codebase is how drift happens.

Using both

They can live in one service. Ent for the entities that are CRUD plus relations, sqlc for the queries that are really reports. They share a database/sql pool (Ent takes an *sql.DB through entsql.OpenDB, and sqlc has a database/sql mode), and a transaction can pass its connection between them as shown in migrating from GORM to sqlc. The cost is two mental models and two sets of generated code, and it is worth paying when each fits half the work.

A rule of thumb

If you can list the queries your service runs, use sqlc. If the service is a model with relations and you cannot predict the questions, use Ent. Services with clear read paths and few writes (APIs over reporting data, event stores) are on the first side. Services that are admin panels, content systems and permission heavy business apps are on the second.

Checklist

  • Choose by starting point: a list of queries or a graph of entities.
  • Count what you will read as joins, and measure those, not single rows.
  • Decide who owns the schema, SQL or Go, and keep one direction.
  • Budget for generated code: 7331 lines against 169 here.
  • Mix them only with a clear split, sharing one pool.

The tools are documented at entgo.io and docs.sqlc.dev.

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.