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

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 -benchwith-benchtime=1sand-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.
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)
}
})
}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")}
}package ent
//go:generate go run -mod=mod entgo.io/ent/cmd/ent generate --feature sql/upsert ./schemaset -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]+ / /"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.449sResults
| Workload | Time | Bytes | Allocations |
|---|---|---|---|
Orders of 20 accounts, loop of QueryOrders | 20.069 ms | 486285 | 13585 |
Orders of 20 accounts, WithOrders() | 1.317 ms | 344605 | 10273 |
| 1000 orders, all columns | 740 µs | 306423 | 9787 |
| 1000 orders, one column | 532 µs | 91097 | 5018 |
Orders of one account, len(All()) | 697 µs | 19280 | 593 |
Orders of one account, Count() | 679 µs | 3352 | 73 |
100 inserts, one Save each | 46.392 ms | 279361 | 7095 |
100 inserts, CreateBulk | 1.675 ms | 250849 | 3580 |
Upsert with OnConflict | 446 µs | 3736 | 88 |
| Upsert as query then update | 1407 µs | 9333 | 216 |
Existence with Exist() | 242 µs | 2824 | 65 |
Existence with Only() | 240 µs | 4288 | 97 |
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
LIMITcan change plans on some queries (the plan example); checkEXPLAINfor yours.
What to do with the numbers
In order of payoff:
- Eliminate per row queries. Eager load or write one query. 15 times in this lab.
- Batch writes.
CreateBulkand bulk updates. 28 times. - Use database side upserts and counters. Three times, and correct under races.
- Select the columns you need on large or wide results.
- Prefer
CountandExistto 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,CreateBulkandOnConflictover loops and read-modify-write. - Select columns for lists, and
CountorExistwhen 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.


