Skip to content
gohacks.dev
Databases

Ent vs GORM for a New Go Service and the Real Trade Offs

Ent against GORM on one PostgreSQL schema: schema as code or struct tags, generated or reflective, relations, migrations, extensions and the measured costs

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

GORM and Ent answer the same question from opposite ends. Both give you an object model over a relational database, with relations, migrations and a query API, and they disagree about when the work happens. GORM does it at run time: structs, tags and reflection. Ent does it at generation time: you describe the schema in Go, run a generator, and get a typed client. That one difference explains most of what you will like and dislike. This article puts them side by side on the same PostgreSQL 18.6 schema, with the benchmark and the code they share, and draws out the trade offs.

Tested with Go 1.25.6, Ent v0.14.5, GORM v1.31.2, gorm.io/driver/postgres v1.6.3, pgx v5.11.0, PostgreSQL 18.6 in Docker, macOS on Apple Silicon (arm64)

It is one of a set: GORM vs sqlc vs Ent vs sqlx is the wide comparison, Ent vs sqlc the other pair, and the tutorials are GORM and Ent. Moving between them is covered in migrating from GORM to Ent.

How each describes data

GORM: a struct is the model. Tags and naming conventions give the table. The relation is a field of another struct. Nothing is generated and nothing is checked until the code runs.

Ent: a schema type with methods: Fields(), Edges(), Indexes(). The generator reads it and writes the entity types, builders, predicates, mutation types and migration code. A relation is an edge.To / edge.From pair, and the foreign key is derived or named explicitly (the lab names account_id with Field("account_id")).

Here are both, as used in the benchmark. The Ent schema is a separate file in Go; the GORM models sit with the code that uses them:

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_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},
	{"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
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          	    2468	    875171 ns/op	    7351 B/op	      93 allocs/op
BenchmarkCreate/ent           	    6013	    438693 ns/op	    2794 B/op	      71 allocs/op
BenchmarkGetByEmail/gorm      	   10866	    215240 ns/op	    4986 B/op	      70 allocs/op
BenchmarkGetByEmail/ent       	   10000	    232698 ns/op	    4304 B/op	      98 allocs/op
BenchmarkListOrders/gorm      	    2752	   1136350 ns/op	   19849 B/op	     614 allocs/op
BenchmarkListOrders/ent       	    6837	    302497 ns/op	   19552 B/op	     607 allocs/op
BenchmarkAccountWithOrders/gorm         	    3944	    554912 ns/op	   32692 B/op	     814 allocs/op
BenchmarkAccountWithOrders/ent          	    4384	    491141 ns/op	   24672 B/op	     700 allocs/op
ok  	example.test/lab	22.421s

Using them

The calls are close in size. Compare the lookup. GORM: s.db.WithContext(ctx).Where("email = ?", email).Take(&a), a string condition. Ent: s.c.Account.Query().Where(account.Email(email)).Only(ctx), a typed predicate. The Ent version cannot misspell the column, cannot compare it with the wrong type, and fails to compile if the field is renamed. The GORM version is shorter to write and fails at run time in all three cases.

Relations differ more. GORM's Preload("Orders") takes a string naming a struct field. Ent's WithOrders() is a generated method, and account.QueryOrders() traverses an edge from a loaded entity. Ent models the data as a graph, and for schemas with many relations (users, groups, permissions, documents) that traversal API is the reason to choose it.

What the benchmark measured

OperationGORMEnt
Insert (µs)875439
Lookup by email (µs)215233
List 50 orders (µs)1136302
Account with 50 orders (µs)555491
Allocations, insert9371
Allocations, lookup7098
Allocations, list of 50614607
Allocations, with orders814700

Read it with the two caveats that matter. The insert gap is GORM's default transaction around every write, which is a setting; the same adjustment closes most of it. The list gap is the LIMIT parameter: Ent writes LIMIT 50 into the statement and GORM binds $2, and on this data PostgreSQL's generic plan for the bound version scans the primary key and discards 9757 rows (the plan comparison). Neither is a property of Ent's design.

What is left is modest. A lookup is a little cheaper in GORM (fewer allocations: 70 against 98), because Ent builds a query object, a predicate and a result entity. Eager loading is slightly cheaper in Ent (700 allocations against 814, 491 µs against 555). The two are in the same range, and a team choosing on throughput alone has chosen on noise.

Generated code

Ent's generated package for this two-entity schema is 7331 lines: builders, predicates, mutations, hooks, migration code and the client. You commit it (or generate it in CI) and you do not read it. It makes compile times longer and pull requests that touch the schema noisy. GORM generates nothing.

That is the central trade: Ent pays a build time and repository size cost to buy compile time safety and a typed API; GORM pays nothing up front and finds its mistakes in tests and production. Renaming a column shows it: Ent's compiler lists every use, while GORM's mistake waits for a query (the rename experiment).

Migrations

GORM's AutoMigrate adds missing tables, columns and indexes and leaves everything else (its limits). Ent's Schema.Create does the same job from the schema, and goes further with Atlas integration: versioned migration files computed from the diff between the schema and the database, which is the safer path for production (Ent migrations with Atlas). Both can be replaced by hand written SQL migrations, and for either the production advice is the same: version your migrations.

Extensibility

  • GORM has hooks, callbacks, plugins and scopes. Cross cutting behaviour (tenant filters, auditing) is added by callbacks that see the statement.
  • Ent has mutation hooks, interceptors, mixins (shared fields and behaviour across entities), a privacy layer for rule based authorization (Ent privacy), and code generation extensions for GraphQL, OpenAPI and gRPC (GraphQL, gRPC and OpenAPI). If the API layer will be generated from the schema, that ecosystem is a strong reason for Ent.

The learning curve and the team

GORM is quick to start and has a large body of examples, answers and habits. It is also easy to use badly: nested creates, Save on partial structs, and N plus one loops are all one line away. Ent has a steeper first day (schema, generation, builders), then fewer surprises, because the API is typed and the generator refuses nonsense. A team of mixed experience gets more from guard rails. A team of three who all know GORM ships faster with it.

Testing and debugging

Both are tested well against a real database with a schema per test (GORM testing, Ent testing). The day to day difference is in what you can see. With GORM, Debug() prints the SQL of a chain, and a dry run returns it as a string. With Ent, a client option logs every statement, and because the builders are ordinary generated Go, a debugger steps into them and shows exactly what query is being assembled. Ent's errors are typed (IsNotFound, IsConstraintError, IsValidationError), while GORM's sentinels need TranslateError to cover constraint failures. Neither removes the need to read the SQL that went out when a query is slow.

Which one

Choose Ent when

  • the domain is a graph with many relations, and you traverse them in queries;
  • you want the compiler to find breakage, or privacy rules and generated APIs;
  • you accept a generation step and a large generated package.

Choose GORM when

  • you need a CRUD service quickly and the team knows it;
  • the schema is a set of mostly independent tables;
  • you will write tests against a real database to cover what the compiler cannot.

If neither feels right, SQL first teams should read Ent vs sqlc and sqlc vs GORM and Ent.

Checklist

  • Decide whether you want compile time checking and a generation step.
  • Compare on the same statements, and read the SQL each tool sends.
  • Turn off or account for GORM's default transaction when comparing writes.
  • Commit Ent's generated code or generate in CI, and check for drift.
  • Version migrations whichever you pick.

The tools are documented at entgo.io and gorm.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.