Skip to content
gohacks.dev
Databases

Ent Performance and Query Shapes Measured on Real Data

Ent query shapes benchmarked on PostgreSQL 18 with 10000 rows: eager loading 15 times faster than a loop, CreateBulk 28 times faster, Count with fewer bytes

9 min read
Summarize in AI
Flat illustration of a database beside the table it holds.

Most Ent performance advice is really query performance advice: do fewer round trips, move fewer bytes, and let the database do the work. Ent makes each of those one method call away, and it makes the slow forms just as easy to write. This article measures twelve query shapes on a seeded PostgreSQL 18.6, in pairs, so each result is a comparison with a cause. The point is which habits matter and by how much, with numbers from one machine.

Tested with Go 1.25.6, Ent v0.14.5, pgx v5.11.0 (stdlib), PostgreSQL 18.6 in Docker on the same machine, Apple M1 Max (10 cores, 32 GB), macOS

It builds on Ent queries, where the SQL for these calls is printed. The cross library comparison is GORM vs sqlc vs Ent vs sqlx, and the Go wide performance cornerstone is profiling and speeding up Go programs.

Method

  • Hardware and software: Apple M1 Max, 10 cores, 32 GB, macOS; Go 1.25.6; Ent v0.14.5; pgx v5.11.0 through database/sql; PostgreSQL 18.6 in a Docker container on the same machine, so there is no network latency in any number.
  • Data: 200 accounts, each with 50 orders (10000 order rows), analysed after loading.
  • Pool: at most 4 open connections.
  • Run: go test -bench with -benchtime=1s and -benchmem, one run, each pair in the same process. Each benchmark call is the whole workload named in its label (20 accounts, 1000 rows, 100 inserts), not one row.
  • Caveat: a laptop is noisy, and the ratios are more trustworthy than the absolute values. A real deployment adds a network round trip to every statement, which makes the round trip savings bigger than shown.
bench_test.goGo
package lab

import (
	"context"
	"database/sql"
	"fmt"
	"sync/atomic"
	"testing"

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

const base = "postgres://postgres:[email protected]:55432/lab?sslmode=disable"

// setup creates 200 accounts with 50 orders each in a fresh schema.
func setup(b *testing.B) *ent.Client {
	b.Helper()
	ctx := context.Background()
	admin, _ := sql.Open("pgx", base)
	admin.Exec("drop schema if exists l0749 cascade")
	admin.Exec("create schema l0749")
	admin.Close()
	db, err := sql.Open("pgx", base+"&search_path=l0749")
	if err != nil {
		b.Fatal(err)
	}
	db.SetMaxOpenConns(4)
	c := ent.NewClient(ent.Driver(entsql.OpenDB(dialect.Postgres, db)))
	if err := c.Schema.Create(ctx); err != nil {
		b.Fatal(err)
	}
	for a := 1; a <= 200; a++ {
		acc := c.Account.Create().SetEmail(fmt.Sprintf("a%d@x.io", a)).SetName("A").SaveX(ctx)
		bulk := make([]*ent.OrderCreate, 0, 50)
		for o := 1; o <= 50; o++ {
			bulk = append(bulk, c.Order.Create().SetAccountID(acc.ID).SetTotalCents(o*100).SetStatus("paid"))
		}
		c.Order.CreateBulk(bulk...).ExecX(ctx)
	}
	db.Exec("analyze")
	return c
}

var counter atomic.Int64

func BenchmarkEnt(b *testing.B) {
	c := setup(b)
	defer c.Close()
	ctx := context.Background()

	// 1. Orders of 20 accounts: a query per account, or one eager load.
	b.Run("orders of 20 accounts/loop", func(b *testing.B) {
		b.ReportAllocs()
		for i := 0; i < b.N; i++ {
			for id := 1; id <= 20; id++ {
				c.Account.GetX(ctx, id).QueryOrders().AllX(ctx)
			}
		}
	})
	b.Run("orders of 20 accounts/WithOrders", func(b *testing.B) {
		b.ReportAllocs()
		for i := 0; i < b.N; i++ {
			c.Account.Query().Where(account.IDLTE(20)).WithOrders().AllX(ctx)
		}
	})

	// 2. Width of the select.
	b.Run("1000 orders/all columns", func(b *testing.B) {
		b.ReportAllocs()
		for i := 0; i < b.N; i++ {
			c.Order.Query().Limit(1000).AllX(ctx)
		}
	})
	b.Run("1000 orders/one column", func(b *testing.B) {
		b.ReportAllocs()
		for i := 0; i < b.N; i++ {
			c.Order.Query().Limit(1000).Select(order.FieldTotalCents).IntsX(ctx)
		}
	})

	// 3. Counting.
	b.Run("count/len of All", func(b *testing.B) {
		b.ReportAllocs()
		for i := 0; i < b.N; i++ {
			_ = len(c.Order.Query().Where(order.AccountID(7)).AllX(ctx))
		}
	})
	b.Run("count/Count", func(b *testing.B) {
		b.ReportAllocs()
		for i := 0; i < b.N; i++ {
			_ = c.Order.Query().Where(order.AccountID(7)).CountX(ctx)
		}
	})

	// 4. Writes: 100 rows.
	b.Run("insert 100 accounts/loop", func(b *testing.B) {
		b.ReportAllocs()
		for i := 0; i < b.N; i++ {
			for j := 0; j < 100; j++ {
				c.Account.Create().SetEmail(fmt.Sprintf("l%d@x.io", counter.Add(1))).SetName("L").SaveX(ctx)
			}
		}
	})
	b.Run("insert 100 accounts/CreateBulk", func(b *testing.B) {
		b.ReportAllocs()
		for i := 0; i < b.N; i++ {
			bulk := make([]*ent.AccountCreate, 0, 100)
			for j := 0; j < 100; j++ {
				bulk = append(bulk, c.Account.Create().SetEmail(fmt.Sprintf("b%d@x.io", counter.Add(1))).SetName("B"))
			}
			c.Account.CreateBulk(bulk...).ExecX(ctx)
		}
	})

	// 5. Upsert in one statement or look first.
	b.Run("upsert/OnConflict", func(b *testing.B) {
		b.ReportAllocs()
		for i := 0; i < b.N; i++ {
			c.Account.Create().SetEmail("[email protected]").SetName("Seven").OnConflictColumns(account.FieldEmail).UpdateName().ExecX(ctx)
		}
	})
	b.Run("upsert/query then update", func(b *testing.B) {
		b.ReportAllocs()
		for i := 0; i < b.N; i++ {
			a, err := c.Account.Query().Where(account.Email("[email protected]")).Only(ctx)
			if err == nil {
				a.Update().SetName("Seven").ExecX(ctx)
			}
		}
	})

	// 6. Existence.
	b.Run("exists/Exist", func(b *testing.B) {
		b.ReportAllocs()
		for i := 0; i < b.N; i++ {
			c.Account.Query().Where(account.Email("[email protected]")).ExistX(ctx)
		}
	})
	b.Run("exists/Only", func(b *testing.B) {
		b.ReportAllocs()
		for i := 0; i < b.N; i++ {
			c.Account.Query().Where(account.Email("[email protected]")).OnlyX(ctx)
		}
	})
}
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
Shell
set -e
GOFLAGS=-mod=mod go generate ./ent
go mod tidy > /dev/null 2>&1
go test -run='^$' -bench=. -benchtime=1s -count=1 -benchmem . 2>&1 | grep -E "^(Benchmark|ok|FAIL|---|[[:space:]]+bench)" | sed -E "s/-[0-9]+ / /"
Text
BenchmarkEnt/orders_of_20_accounts/loop         	      50	  20069235 ns/op	  486285 B/op	   13585 allocs/op
BenchmarkEnt/orders_of_20_accounts/WithOrders   	     880	   1317444 ns/op	  344605 B/op	   10273 allocs/op
BenchmarkEnt/1000_orders/all_columns            	    1592	    739949 ns/op	  306423 B/op	    9787 allocs/op
BenchmarkEnt/1000_orders/one_column             	    2292	    532045 ns/op	   91097 B/op	    5018 allocs/op
BenchmarkEnt/count/len_of_All                   	    1736	    696529 ns/op	   19280 B/op	     593 allocs/op
BenchmarkEnt/count/Count                        	    1773	    679347 ns/op	    3352 B/op	      73 allocs/op
BenchmarkEnt/insert_100_accounts/loop           	      25	  46392252 ns/op	  279361 B/op	    7095 allocs/op
BenchmarkEnt/insert_100_accounts/CreateBulk     	     969	   1674822 ns/op	  250849 B/op	    3580 allocs/op
BenchmarkEnt/upsert/OnConflict                  	    2728	    446310 ns/op	    3736 B/op	      88 allocs/op
BenchmarkEnt/upsert/query_then_update           	     902	   1406639 ns/op	    9333 B/op	     216 allocs/op
BenchmarkEnt/exists/Exist                       	    6243	    241895 ns/op	    2824 B/op	      65 allocs/op
BenchmarkEnt/exists/Only                        	    4954	    240342 ns/op	    4288 B/op	      97 allocs/op
ok  	example.test/lab	17.449s

Results

WorkloadTimeBytesAllocations
Orders of 20 accounts, loop of QueryOrders20.069 ms48628513585
Orders of 20 accounts, WithOrders()1.317 ms34460510273
1000 orders, all columns740 µs3064239787
1000 orders, one column532 µs910975018
Orders of one account, len(All())697 µs19280593
Orders of one account, Count()679 µs335273
100 inserts, one Save each46.392 ms2793617095
100 inserts, CreateBulk1.675 ms2508493580
Upsert with OnConflict446 µs373688
Upsert as query then update1407 µs9333216
Existence with Exist()242 µs282465
Existence with Only()240 µs428897

Eager loading against a loop: 15 times

The loop fetches each account and then its orders, which is 40 statements for 20 accounts: 20.069 ms. WithOrders() runs two: one for the accounts and one for all their orders with an IN list: 1.317 ms. That is a factor of 15 for 20 accounts, and the gap grows with the number of parents because the loop pays a fixed cost per parent. This is the largest and most common win, and it is the Ent form of the N plus one fix measured in GORM Preload and Joins.

Notice that the allocation counts are close (13585 against 10273). The loop's cost is not memory, it is waiting: 40 round trips against 2. On a database a millisecond away, the loop would be about 40 ms slower on top of this.

Selecting less: 3.4 times fewer bytes

Loading 1000 orders with all columns used 306423 bytes and 9787 allocations. Selecting one column with Select(order.FieldTotalCents).IntsX used 91097 bytes and 5018 allocations and finished in 532 µs against 740 µs. The speed gain is modest here because the order rows are narrow (four small columns). With wide rows (text bodies, JSON) the savings in bytes and time are larger, as the GORM measurement of a 4 KB column showed (4.3 times, in the performance mistakes list). The rule: list endpoints select what they render.

Counting: allocations, not time

At 50 matching rows, Count() and len(All()) took the same time (679 and 697 µs), and PostgreSQL's work dominated both. The difference is on the Go side: Count allocated 3352 bytes against 19280 and made 73 allocations against 593, because it never builds 50 entities. At 50 rows that is a rounding error. At 50000 rows it is a large amount of memory and garbage collection for a number you could have had for free. Use Count when you want a number, and Exist when you want a yes or no.

Bulk writes: 28 times

One hundred inserts took 46.392 ms in a loop and 1.675 ms with CreateBulk, 28 times faster, with 3580 allocations against 7095. The loop pays a transaction per row (each Save is its own statement and commit), and the bulk form is one multi row statement. If you must keep individual rows' results and hooks, CreateBulk still runs the hooks per builder (hooks). Beware the parameter limit: PostgreSQL allows 65535 bind parameters per statement, so a bulk of rows times columns above that fails, and the fix is to chunk (a few hundred to a few thousand rows per call).

Upsert: 3.2 times

OnConflictColumns(account.FieldEmail).UpdateName() is one statement (INSERT ... ON CONFLICT ... DO UPDATE) and took 446 µs. The alternative, query for the row and update it, is two statements and took 1407 µs and more than twice the allocations. The single statement is also correct under concurrency, since two requests cannot both decide the row is missing. The builder needs the sql/upsert feature at generation time.

Exist and Only

Both took about 240 µs. Exist() allocated 2824 bytes and 65 allocations, Only() 4288 bytes and 97, because it builds the entity. The time is the same because the query is the same shape. Use Exist for the yes or no and Only when you need the row, and pick on what the code needs, not on speed.

What this does not measure

  • Network latency. Every round trip here is a Docker local call. Remote databases amplify the loop and upsert gaps.
  • Contention. One goroutine, no concurrent writers, no locks.
  • Large result sets. The biggest was 1000 rows. Memory and GC effects grow with size.
  • Cold caches. The data fits in PostgreSQL's cache.
  • The generic plan effect. Parameterised LIMIT can change plans on some queries (the plan example); check EXPLAIN for yours.

What to do with the numbers

In order of payoff:

  1. Eliminate per row queries. Eager load or write one query. 15 times in this lab.
  2. Batch writes. CreateBulk and bulk updates. 28 times.
  3. Use database side upserts and counters. Three times, and correct under races.
  4. Select the columns you need on large or wide results.
  5. Prefer Count and Exist to loading entities.

Everything else is noise until a profile says otherwise (CPU profiling, allocation profiles).

Checklist

  • Count statements per request; fewer is almost always faster.
  • Use WithX, CreateBulk and OnConflict over loops and read-modify-write.
  • Select columns for lists, and Count or Exist when you need a fact.
  • Benchmark pairs with a cause, and read the SQL.
  • Repeat on your own data and hardware before acting.

The relevant API is in the Ent eager loading and upsert documentation.

Taggedbenchmark
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.