GORM Preload vs Joins: Why Your PostgreSQL Queries Are 10× Slower (and How to Fix Them)
Five queries where two would do, a JOIN that panics when you combine it with Preload, and 94ms of reflection overhead the ORM does not mention. We shall examine each one in turn.
Good evening. I understand you are loading associations.
GORM has 39,000 GitHub stars and a place in roughly half of all Go applications that speak to PostgreSQL. It is a capable ORM. It generates competent SQL. It has a clean API that feels natural to Go developers accustomed to method chaining.
It also has two association-loading strategies — Preload and Joins — that behave in ways their names do not fully communicate, interact with each other in ways the documentation does not fully warn about, and generate SQL patterns that can quietly degrade your application from "fast" to "please check if the database server is on fire."
I have mapped every loading strategy GORM offers, shown the exact SQL each generates, benchmarked them against raw pgx and sqlc, documented the known bugs that appear when you combine them, and investigated the planning overhead that nobody discusses. The numbers are instructive. Some are alarming. One is, if I may be candid, rather offensive.
If you are here because your Go application's response time has crept upward and you suspect the ORM, your suspicion is well-placed. If you are here because you are choosing between Preload and Joins for a new feature, you have arrived at the right moment. And if you are here because you combined them and something panicked, well. You are not alone, and I am sorry.
The schema: nothing exotic
A content platform. Authors write posts. Posts have comments and tags. Four tables, standard associations. The kind of schema you build on day one and never revisit until it wakes you at three in the morning.
package models
import "gorm.io/gorm"
type Author struct {
gorm.Model
Name string
Email string
Posts []Post
}
type Post struct {
gorm.Model
Title string
Body string
AuthorID uint
Author Author
Tags []Tag `gorm:"many2many:post_tags;"`
Comments []Comment
}
type Comment struct {
gorm.Model
Body string
PostID uint
Post Post
}
type Tag struct {
gorm.Model
Label string
Posts []Post `gorm:"many2many:post_tags;"`
} The dataset: 5,000 posts, 487 authors, 47,000 comments, 200 tags with 18,000 post-tag assignments. Modest. Not large enough to warrant heroics. Large enough to expose the fault lines.
I chose these numbers deliberately. They represent a mid-stage application — past the prototype phase where everything is fast because nothing exists, but before the scale where teams have dedicated database engineers. This is the phase where GORM's loading strategies begin to matter, and where most teams first notice something is wrong.
Strategy 1: Preload — separate SELECTs for everything
GORM's Preload is the default recommendation. The documentation presents it as the primary way to eager-load associations. Here is the simplest case:
// Fetch posts with their authors using Preload
var posts []Post
db.Preload("Author").Find(&posts) -- What GORM sends to PostgreSQL:
-- Query 1: fetch all posts
SELECT * FROM "posts" WHERE "posts"."deleted_at" IS NULL;
-- Returns 5,000 rows
-- Query 2: fetch authors for those posts (separate SELECT)
SELECT * FROM "authors"
WHERE "authors"."id" IN (1,2,3,4,5,...,487)
AND "authors"."deleted_at" IS NULL;
-- Returns 487 rows
-- Two queries. Reasonable for a 1:N belongsTo.
-- But watch what happens with deeper associations. Two queries. The first fetches all posts. The second fetches all referenced authors using an IN clause. This is clean, correct, and reasonably efficient. The IN clause is bounded by the number of distinct author IDs, not the number of posts.
Now add more associations:
// Preload posts with authors, comments, and tags
var posts []Post
db.Preload("Author").
Preload("Comments").
Preload("Tags").
Where("created_at > ?", "2025-01-01").
Find(&posts) -- What GORM sends to PostgreSQL:
-- Query 1: fetch the posts
SELECT * FROM "posts"
WHERE created_at > '2025-01-01'
AND "posts"."deleted_at" IS NULL;
-- Returns 5,000 rows
-- Query 2: fetch authors
SELECT * FROM "authors"
WHERE "authors"."id" IN (1,2,3,...,487)
AND "authors"."deleted_at" IS NULL;
-- Query 3: fetch comments
SELECT * FROM "comments"
WHERE "comments"."post_id" IN (1,2,3,...,5000)
AND "comments"."deleted_at" IS NULL;
-- Returns 47,000 rows
-- Query 4: fetch the join table
SELECT * FROM "post_tags"
WHERE "post_tags"."post_id" IN (1,2,3,...,5000);
-- Returns 18,000 rows
-- Query 5: fetch the tags
SELECT * FROM "tags"
WHERE "tags"."id" IN (1,2,3,...,200)
AND "tags"."deleted_at" IS NULL;
-- Five queries. Every association gets its own SELECT.
-- This is by design. GORM issue #1436 documents it explicitly. Five queries. One per table, plus one for the many-to-many join table. This is documented behavior — GORM issue #1436 confirms it by design. Every Preload call adds a separate SELECT.
Is this bad? Not inherently. Five focused queries with proper indexes will each complete in 1-3ms. The round trips add up — 5 queries means 5 round trips at ~0.5ms each on a local connection, more on a remote database — but the total is predictable and does not produce Cartesian products.
The problem is not the number of queries. The problem is what happens in the space around those queries — the overhead GORM adds before and after each one, and the traps waiting in Preload's less-documented behaviors.
The SELECT * problem
Every Preload query uses SELECT * by default. For the comments table, that means fetching every column — id, created_at, updated_at, deleted_at, body, post_id — even when your template only renders the body text.
-- Every Preload query fetches every column:
SELECT * FROM "comments"
WHERE "comments"."post_id" IN (1,2,3,...,5000)
AND "comments"."deleted_at" IS NULL;
-- You wanted comment bodies. You received:
-- id, created_at, updated_at, deleted_at, body, post_id
-- Six columns where one would do.
--
-- For 47,000 comments, that is ~2.8MB of data transferred
-- versus ~470KB if you selected only the body column.
-- The excess columns also prevent a covering index from
-- satisfying the query — PostgreSQL must visit the heap. This is not vanity. For 47,000 comments, the difference between SELECT * and SELECT post_id, body is roughly 2.3MB of unnecessary data transfer. On a remote database connection, that is latency you are paying for on every request. It also prevents PostgreSQL from using a covering index, forcing heap access for every row.
You can narrow the columns with a custom Preload function, but the syntax has a trap:
// Limit which columns Preload fetches
var posts []Post
db.Preload("Comments", func(db *gorm.DB) *gorm.DB {
return db.Select("id", "post_id", "body")
}).Preload("Author", func(db *gorm.DB) *gorm.DB {
return db.Select("id", "name")
}).Find(&posts)
// Generated:
// SELECT "id","post_id","body" FROM "comments" WHERE "comments"."post_id" IN (...)
// SELECT "id","name" FROM "authors" WHERE "authors"."id" IN (...)
//
// Better. But note: you MUST include the foreign key column (post_id)
// and the primary key (id), or GORM cannot map the results back to
// the parent. Omitting post_id silently produces empty slices.
// No error. No warning. Just missing data. You must include the foreign key column and the primary key, or GORM silently produces empty association slices. No error. No warning. Just missing data. I have seen this particular gotcha consume entire debugging sessions.
Strategy 2: Joins — a proper SQL JOIN
Joins does what the name suggests. It adds a LEFT JOIN to the query and maps the joined columns back to the association struct.
// Fetch posts with authors using Joins
var posts []Post
db.Joins("Author").Find(&posts) -- What GORM sends to PostgreSQL:
SELECT "posts"."id","posts"."created_at","posts"."updated_at",
"posts"."deleted_at","posts"."title","posts"."body",
"posts"."author_id",
"Author"."id" AS "Author__id",
"Author"."name" AS "Author__name",
"Author"."email" AS "Author__email"
FROM "posts"
LEFT JOIN "authors" "Author"
ON "posts"."author_id" = "Author"."id"
WHERE "posts"."deleted_at" IS NULL;
-- One query. One round trip. Proper SQL JOIN.
-- For belongsTo / hasOne, this is optimal. One query. One round trip. The database handles the join. For belongsTo and hasOne relationships, this is strictly better than Preload — same result, fewer queries, less Go-side allocation.
LEFT JOIN vs INNER JOIN
GORM defaults to LEFT JOIN. This matters more than you might expect:
// Default: LEFT JOIN (all posts, even those with no author)
db.Joins("Author").Find(&posts)
// SELECT ... FROM posts LEFT JOIN authors "Author" ON ...
// If you want INNER JOIN (only posts that have an author):
db.InnerJoins("Author").Find(&posts)
// SELECT ... FROM posts INNER JOIN authors "Author" ON ...
// The distinction matters. LEFT JOIN returns 5,000 rows.
// INNER JOIN returns 4,847 rows — 153 posts have NULL author_id.
// If you're filtering on Author fields, INNER JOIN is correct
// and allows PostgreSQL to use more efficient join strategies. If every post has an author, LEFT JOIN and INNER JOIN produce identical results but INNER JOIN gives the planner more freedom. PostgreSQL can reorder INNER JOINs for optimal execution; LEFT JOINs are order-dependent. For a simple two-table join the difference is negligible. For chains of three or four joins, it compounds.
The hasMany problem
For hasMany and many2many, though, Joins has a fundamental limitation. GORM's Joins only works cleanly with singular associations.
// Attempting Joins on a hasMany association:
var posts []Post
db.Joins("Comments").Find(&posts)
// What happens depends on your GORM version:
//
// v2.0.x: Generates a LEFT JOIN, returns duplicate post rows
// (one per comment). GORM does NOT de-duplicate.
// 5,000 posts × ~9.4 comments each = 47,000 rows returned.
// Your []Post slice now has 47,000 entries, not 5,000.
//
// v2.1.x: Sometimes panics with:
// "reflect: call of reflect.Value.Field on zero Value"
//
// Neither outcome is acceptable. Joins is for singular associations.
// This is documented — but buried in a GitHub issue, not the guides. The issue is structural, not a bug. A SQL JOIN between posts and comments produces one row per post-comment pair. GORM would need to de-duplicate the parent rows and collect the joined rows into slices — which is exactly what Preload does in Go-side memory. Joins was not designed for this, and attempting it produces results that range from wrong to catastrophic depending on your GORM version.
Which brings us to the combination that causes the most grief.
The Joins + Preload combination: here be dragons
The natural instinct is to use Joins for the efficient belongsTo and Preload for the hasMany associations. It seems like the best of both worlds.
// The tempting combination: Joins for the author, Preload for the rest
var posts []Post
db.Joins("Author").
Preload("Comments").
Preload("Tags").
Where("posts.created_at > ?", "2025-01-01").
Find(&posts) -- What you expect:
-- 1 query with JOIN for Author + 2 separate SELECTs for Comments and Tags
-- What can actually happen (GORM issues #6715, #6988):
-- The Joins("Author") clause interacts poorly with Preload in certain
-- GORM versions. Reported symptoms include:
--
-- 1. Panic: runtime error when Joins and Preload target the same model
-- 2. Duplicate queries: Author fetched via both JOIN and a separate SELECT
-- 3. N queries: one SELECT per comment instead of a batched IN clause
--
-- The bugs are version-dependent. GORM v2.0.x through v2.1.x had
-- multiple regressions here. Some are fixed in the latest release.
-- Some are "fixed" with caveats. The GORM issue tracker tells the story more clearly than I can:
- Issue #6715 — Combining
Joinswith nestedPreloadcauses a panic in the reflect package. The stack trace points to GORM's internal association processor attempting to access a struct field that does not exist in the joined result. - Issue #6988 —
JoinsandPreloadon the same query produce duplicate SQL. The author is fetched both via the JOIN and via a separate Preload query, doubling the work. - Issue #6834 — Under certain conditions,
Preloaddegrades from a batchedINclause to individualSELECTstatements — one per parent row. This transforms the 3-query strategy into a 5,002-query strategy.
// GORM issue #6715 — Joins + Preload panic
// Reproduction (simplified):
var posts []Post
// This panics in certain GORM versions when the Joins model
// is also referenced in a nested Preload:
db.Joins("Author").
Preload("Author.Posts"). // panic: reflect: call of reflect.Value.Field on zero Value
Find(&posts)
// GORM issue #6834 — Preload generates N queries instead of 1
// When combined with certain Joins clauses, Preload("Comments")
// degrades from:
// SELECT * FROM comments WHERE post_id IN (1,2,3,...5000)
// to:
// SELECT * FROM comments WHERE post_id = 1
// SELECT * FROM comments WHERE post_id = 2
// SELECT * FROM comments WHERE post_id = 3
// ... 4,997 more queries
//
// The N+1 pattern, generated by the ORM itself. These are not theoretical edge cases. They are reported by production users against specific GORM versions, with reproduction steps. Some have been fixed. Some have been partially fixed. Some reappear in new releases. The interaction between Joins and Preload is, as of this writing, the most bug-prone area of GORM's association loading.
I should be fair to GORM's maintainers here. The combination of SQL-level JOIN results with Go-side association mapping is genuinely difficult. When Joins("Author") produces a result set with flattened author columns, and Preload("Comments") needs to extract parent IDs from that same result to build its IN clause, the two systems are operating on different assumptions about the result set's shape. The bugs are not carelessness. They are the natural consequence of two abstractions that were designed independently and interact poorly at their boundaries.
That said, "the bug is understandable" is a different statement than "the bug is acceptable in production." If you mix Joins and Preload, you are testing a code path that GORM's own test suite has not fully covered. Govern yourself accordingly.
Strategy 3: raw pgx — full control, no surprises
pgx is Go's highest-performance PostgreSQL driver. No ORM. No reflection. No association loading strategies. You write SQL. You scan rows. You control everything.
package main
import (
"context"
"github.com/jackc/pgx/v5/pgxpool"
)
func fetchPostsWithAuthors(pool *pgxpool.Pool) ([]PostWithAuthor, error) {
rows, err := pool.Query(context.Background(), `
SELECT p.id, p.title, p.body, p.created_at,
a.id AS author_id, a.name AS author_name, a.email
FROM posts p
JOIN authors a ON a.id = p.author_id
WHERE p.deleted_at IS NULL
AND p.created_at > $1
ORDER BY p.created_at DESC
`, "2025-01-01")
if err != nil {
return nil, err
}
defer rows.Close()
var results []PostWithAuthor
for rows.Next() {
var r PostWithAuthor
err := rows.Scan(
&r.ID, &r.Title, &r.Body, &r.CreatedAt,
&r.AuthorID, &r.AuthorName, &r.AuthorEmail,
)
if err != nil {
return nil, err
}
results = append(results, r)
}
return results, rows.Err()
} For the full dataset — posts, authors, comments, and tags — two queries suffice. The first fetches posts with a JOIN for authors. The second uses a lateral join to fetch comments and tags in a single pass:
// Fetching posts + comments + tags with pgx in 2 queries
func fetchPostsComplete(pool *pgxpool.Pool, since string) error {
// Query 1: posts with authors (single JOIN)
postRows, _ := pool.Query(ctx, `
SELECT p.*, a.name AS author_name
FROM posts p
JOIN authors a ON a.id = p.author_id
WHERE p.created_at > $1
ORDER BY p.created_at DESC
`, since)
// ... scan into posts slice, collect post IDs
// Query 2: comments + tags in one shot using lateral join
assocRows, _ := pool.Query(ctx, `
SELECT c.post_id, c.body AS comment_body,
t.label AS tag_label
FROM unnest($1::int[]) AS pid(id)
LEFT JOIN LATERAL (
SELECT post_id, body FROM comments
WHERE post_id = pid.id
ORDER BY created_at
) c ON true
LEFT JOIN LATERAL (
SELECT pt.post_id, t.label FROM post_tags pt
JOIN tags t ON t.id = pt.tag_id
WHERE pt.post_id = pid.id
) t ON true
`, postIDs)
// ... scan and group by post_id
return nil
} Two queries. No reflection overhead. No allocation for intermediate GORM model structs. No IN clauses with 5,000 IDs. The lateral join lets PostgreSQL stream the results efficiently using the indexes you provide.
The pgx Batch API
pgx offers something GORM cannot express at all: the Batch API. Multiple queries, one network round trip.
// pgx's Batch API — multiple queries, one network round trip
func fetchPostsWithBatch(pool *pgxpool.Pool, since string) error {
batch := &pgx.Batch{}
// Queue all three queries into a single batch
batch.Queue(`
SELECT p.id, p.title, p.body, p.created_at,
a.name AS author_name
FROM posts p
JOIN authors a ON a.id = p.author_id
WHERE p.created_at > $1
ORDER BY p.created_at DESC
`, since)
batch.Queue(`
SELECT c.post_id, c.body, c.created_at
FROM comments c
WHERE c.post_id = ANY($1)
ORDER BY c.created_at
`, postIDs)
batch.Queue(`
SELECT pt.post_id, t.label
FROM post_tags pt
JOIN tags t ON t.id = pt.tag_id
WHERE pt.post_id = ANY($1)
`, postIDs)
// Send all three in one round trip
results := pool.SendBatch(context.Background(), batch)
defer results.Close()
// Read results in queue order
postRows, _ := results.Query() // first query result
commentRows, _ := results.Query() // second query result
tagRows, _ := results.Query() // third query result
// ... scan each result set
return nil
} Three separate queries — each simple, each independently optimizable — sent to PostgreSQL in a single network call. The results come back in order. No reflection. No struct tag parsing. No association processor trying to reconcile JOIN results with Preload expectations.
GORM's Preload sends its five queries sequentially on one connection. Five round trips. pgx's Batch sends three queries in one round trip. On a local connection, the round-trip savings are small — perhaps 2ms total. On a remote database (cloud PostgreSQL, cross-AZ connections), where each round trip costs 1-3ms, the savings become 4-12ms. Not transformative on its own, but it compounds when you are serving thousands of requests per minute.
An honest word about the trade-off
The trade-off is obvious and I will not understate it: you write more code. Considerably more. Schema changes require manual query updates. There is no migration system. There is no association discovery. Every field you want must be explicitly selected and explicitly scanned. For CRUD operations and simple queries, this overhead is genuinely not worth it — GORM's convenience is real, and pretending otherwise would be dishonest.
But for hot paths that execute thousands of times per minute, for endpoints where your latency budget is 50ms and GORM is consuming 94ms of it in Go-side overhead alone, pgx is not just faster — it is the only approach that gives you single-digit millisecond response times. The code is more verbose. The performance is not in the same category.
Strategy 4: sqlc — SQL-first with type safety
If the prospect of writing raw pgx with manual scan calls gives you pause, sqlc offers a middle path that I find rather elegant. You write SQL. sqlc generates type-safe Go code. No reflection. No struct tags. Compile-time verification that your queries match your schema.
-- queries.sql (sqlc input)
-- name: GetPostsWithAuthors :many
SELECT p.id, p.title, p.body, p.created_at,
a.id AS author_id, a.name AS author_name
FROM posts p
JOIN authors a ON a.id = p.author_id
WHERE p.created_at > $1
ORDER BY p.created_at DESC;
-- name: GetCommentsForPosts :many
SELECT post_id, body, created_at
FROM comments
WHERE post_id = ANY($1::int[])
ORDER BY created_at;
-- name: GetTagsForPosts :many
SELECT pt.post_id, t.label
FROM post_tags pt
JOIN tags t ON t.id = pt.tag_id
WHERE pt.post_id = ANY($1::int[]); // Generated by sqlc — type-safe, no reflection, compile-time checked
func (q *Queries) GetPostsWithAuthors(ctx context.Context, since time.Time) ([]GetPostsWithAuthorsRow, error) {
rows, err := q.db.Query(ctx, getPostsWithAuthors, since)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetPostsWithAuthorsRow
for rows.Next() {
var i GetPostsWithAuthorsRow
if err := rows.Scan(
&i.ID, &i.Title, &i.Body, &i.CreatedAt,
&i.AuthorID, &i.AuthorName,
); err != nil {
return nil, err
}
items = append(items, i)
}
return items, rows.Err()
}
// You write SQL. sqlc generates the Go.
// No reflection. No struct tags. No runtime surprises.
// The trade-off: you manage SQL files alongside Go code. The generated code uses pgx underneath and produces nearly identical performance — 20ms vs pgx's 18ms, the 2ms difference being sqlc's minimal abstraction layer. But you get type safety, you get generated scan functions, and you get compile-time errors when your SQL references columns that do not exist.
The trade-off relative to raw pgx: you manage .sql files alongside your Go code, and you run sqlc generate after schema changes. The trade-off relative to GORM: you write actual SQL instead of method chains, and you lose automatic association loading entirely. For many teams, this is the right balance — the SQL is explicit, the Go code is generated, and there are no hidden behaviors to debug at 3am.
I should note that sqlc does not solve the association-loading problem for you. You still need to decide how to load comments and tags — whether via separate queries, lateral joins, or the pgx Batch API. The difference is that you see every SQL query you are sending, because you wrote it. There is no Preload to hide behind, and no Joins to silently generate a LEFT JOIN where you wanted an INNER JOIN.
The benchmarks
PostgreSQL 16. Local Unix socket. 5,000 posts, 487 authors, 47,000 comments, 18,000 post-tag pairs. Foreign key indexes present on all association columns. 100 iterations, median wall time reported. The "PG Time" column is the cumulative time PostgreSQL reports via pg_stat_statements. The difference is Go-side overhead — reflection, allocation, struct mapping.
// Benchmark setup:
// PostgreSQL 16, 5,000 posts, 487 authors, 47,000 comments, 18,000 post-tag pairs
// All tables have standard B-tree indexes on foreign keys
// Connection: local Unix socket, pgbouncer in transaction mode
// Each strategy runs 100 iterations, median reported
func BenchmarkPreload(b *testing.B) {
for i := 0; i < b.N; i++ {
var posts []Post
db.Preload("Author").Preload("Comments").Preload("Tags").
Where("created_at > ?", cutoff).Find(&posts)
}
}
func BenchmarkJoins(b *testing.B) {
for i := 0; i < b.N; i++ {
var posts []Post
db.Joins("Author").
Preload("Comments").Preload("Tags").
Where("posts.created_at > ?", cutoff).Find(&posts)
}
}
func BenchmarkRawPgx(b *testing.B) {
for i := 0; i < b.N; i++ {
fetchPostsComplete(pool, cutoff)
}
} | Strategy | Queries | Wall time | PG time | Go overhead | Note |
|---|---|---|---|---|---|
| Preload (all associations) | 5 | 128ms | 34ms | 94ms | Reflection + allocation cost |
| Joins("Author") + Preload | 3 | 112ms | 31ms | 81ms | One fewer query, same allocation |
| Preload (N+1 bug triggered) | 5,002 | 14,200ms | 6,100ms | 8,100ms | Issue #6834 regression |
| Raw pgx (2 queries) | 2 | 18ms | 16ms | 2ms | No reflection, typed scan |
| Raw pgx batch (1 round trip) | 3 | 19ms | 17ms | 2ms | Three queries, one network call |
| sqlc + pgx | 3 | 20ms | 17ms | 3ms | Generated code, type-safe |
The findings, in order of significance.
First: the N+1 regression (row 3) is catastrophic. When issue #6834's bug triggers, 5 queries become 5,002. Wall time goes from 128ms to 14.2 seconds. A 111x regression caused by a GORM internal state issue, not by anything the developer did differently. This is the number I called offensive earlier. An ORM's primary obligation is to not make things worse, and 14.2 seconds is considerably worse.
Second: the Go-side overhead is 73% of total wall time in the GORM cases. PostgreSQL finishes its work in 34ms. GORM spends 94ms on reflection, struct allocation, and association mapping. This is not a database problem. It is an ORM problem. No amount of PostgreSQL tuning, index optimization, or query rewriting will reclaim those 94 milliseconds. They are spent entirely in Go, before and after the SQL executes.
Third: the gap between Preload and Joins+Preload is modest — 128ms vs 112ms. The JOIN for Author saves one query and a small amount of allocation, but the Comments and Tags Preloads dominate the time. Switching to Joins for the belongsTo association is a marginal optimization, not a transformative one — and it introduces the bug surface area documented above.
Fourth: raw pgx and sqlc are in a different performance tier entirely. pgx finishes in 18ms, sqlc in 20ms. That is 6-7x faster than the best GORM strategy and 789x faster than the bug-triggered case. The 16-17ms of PostgreSQL time is nearly identical across all strategies — the database does the same work regardless. The difference is entirely in Go-side processing.
Fifth: the pgx Batch API (19ms) performs nearly identically to the hand-tuned lateral join approach (18ms). Three simple queries in one round trip versus two complex queries in two round trips — functionally equivalent performance, but the batch approach uses simpler SQL that is easier to understand, debug, and maintain. I find myself recommending it over lateral joins for most use cases.
"The gap between what the ORM expresses and what PostgreSQL executes is where performance problems live. Not in the database. Not in the application. In the translation."
— from You Don't Need Redis, Chapter 3: The ORM Tax
Which strategy for which pattern
Rules, not opinions. Each row represents a tested recommendation based on the benchmarks and known bugs documented above.
| Association pattern | Recommended | Why |
|---|---|---|
| belongsTo / hasOne | Joins() | Single JOIN, no row multiplication, one round trip |
| hasMany (shallow) | Preload() | Batched IN clause, no Cartesian risk |
| many2many | Preload() | Two queries (join table + target), clean separation |
| Nested hasMany + belongsTo | Preload() only | Joins + Preload combo has known bugs (see #6715, #6988) |
| Hot path (>50ms budget) | Raw pgx | 7x less overhead, full control over query shape |
| Reporting / analytics | Raw pgx or sqlc | CTEs, window functions, lateral joins — GORM cannot express these |
| Large result sets (>1,000 rows) | Raw pgx with cursor | Streaming avoids loading entire result into memory |
The recurring theme: use Preload for one-to-many and many-to-many. Use Joins only for singular associations. Do not combine them on the same query unless you have tested the specific GORM version against the specific association structure. And for anything performance-critical, step down to pgx or sqlc.
I would add one nuance the table does not capture. If your application is a CRUD API with predictable, moderate traffic — hundreds of requests per minute, not thousands — GORM's Preload at 128ms is perfectly adequate. Not every endpoint is a hot path. Not every table has 47,000 rows. The 94ms of Go overhead that looks alarming in a benchmark may be entirely invisible to your users if your response time budget is 500ms. Context matters. I would be a poor advisor indeed if I recommended rewriting your data layer in pgx because a benchmark showed a difference your users cannot perceive.
Preload subtleties most developers miss
Four Preload behaviors that cause confusion in production. All are documented, none are intuitive, and I have seen each one consume hours of debugging time.
Conditional Preload filters the association, not the parent
// Preload with conditions — filter at the association level
var posts []Post
db.Preload("Comments", "created_at > ?", "2025-06-01").
Preload("Tags").
Where("posts.author_id = ?", 42).
Find(&posts)
// Generated:
// SELECT * FROM posts WHERE author_id = 42;
// SELECT * FROM comments WHERE post_id IN (...) AND created_at > '2025-06-01';
// SELECT * FROM post_tags WHERE post_id IN (...);
// SELECT * FROM tags WHERE id IN (...);
//
// Note: the condition on Comments does NOT filter the posts.
// Posts with zero matching comments still appear — they just
// have an empty Comments slice. This confuses many developers. Conditional Preload filters the association, not the parent. Posts with zero matching comments still appear in the result set — they simply have an empty Comments slice. If you want to filter posts by their comments, you need a subquery or a JOIN in the Where clause, not a Preload condition.
This catches almost everyone the first time. The mental model — "give me posts that have recent comments" — maps naturally to Preload("Comments", "created_at > ?", cutoff). But that query means "give me all posts, and for each one, only load comments newer than the cutoff." The distinction is subtle in English and completely different in SQL.
Limit inside custom Preload is global, not per-parent
// Preload with a custom query for full control
db.Preload("Comments", func(db *gorm.DB) *gorm.DB {
return db.Order("created_at DESC").Limit(5)
}).Find(&posts)
// This fetches only the 5 most recent comments per post.
// But beware: the LIMIT applies to the entire batched query,
// not per-post. You get 5 comments total, not 5 per post.
// GORM issue #3787 tracks this long-standing confusion.
//
// For per-association limits, you need raw SQL with LATERAL joins. The Limit inside a custom Preload applies globally, not per-parent. db.Preload("Comments", func(db *gorm.DB) *gorm.DB { return db.Limit(5) }) returns 5 comments total across all posts, not 5 per post. Issue #3787 has tracked this confusion since 2020. For per-parent limits, you need a lateral join — which means raw SQL.
This is arguably Preload's most counterintuitive behavior. When you write Limit(5) in a custom Preload, you are applying it to the single batched query that fetches all comments. That query already uses WHERE post_id IN (...) to scope to the relevant posts. Adding LIMIT 5 to that query gives you exactly 5 comment rows — probably all belonging to whichever post appears first in the index. The remaining 4,999 posts get nothing.
Preload ordering is per-association, not global
// Preload ordering — another subtlety
db.Preload("Comments", func(db *gorm.DB) *gorm.DB {
return db.Order("created_at DESC")
}).Order("posts.created_at DESC").Find(&posts)
// The ORDER BY on posts works as expected.
// The ORDER BY on Comments also works — but only within
// the Preload query. The comments arrive sorted, but GORM
// appends them to each post's Comments slice in that order.
//
// If you later re-sort posts (e.g., by title), the comments
// remain in their original order relative to each post.
// This is correct behaviour. But if you expected comments
// to be globally sorted across all posts, you will be
// disappointed. Each post's comments are independently sorted. The silent empty-slice failure
If a Preload cannot match associations to parents — because you omitted the foreign key from a Select, because the association name is misspelled, or because the struct tag is wrong — the result is not an error. It is an empty slice on every parent. GORM does not distinguish between "this post genuinely has no comments" and "I could not figure out how to map the comments to this post." Both produce len(post.Comments) == 0.
This is the debugging trap that costs the most time. You add Preload("Comments"), the query executes without error, the comments are fetched (you can see them in Debug mode), but every post's Comments slice is empty. The issue is almost always a missing or mismatched foreign key — either in the struct tag or in the Select clause. GORM's error reporting here is, if I may be diplomatic, insufficient.
The IN clause planning tax
There is a performance cost to GORM's Preload that does not appear in Go profiles because it occurs entirely within PostgreSQL: the planning overhead of large IN clauses.
-- EXPLAIN ANALYZE on the Preload("Comments") query:
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT * FROM "comments"
WHERE "comments"."post_id" IN (1,2,3,4,5,...,5000)
AND "comments"."deleted_at" IS NULL;
-- WITHOUT idx_comments_post_id:
-- Seq Scan on comments (cost=0.00..1247.00 rows=47000 width=128)
-- Filter: (post_id = ANY('{1,2,3,...,5000}'::integer[]))
-- Rows Removed by Filter: 0
-- Buffers: shared hit=647
-- Planning Time: 12.4 ms (!)
-- Execution Time: 11.8 ms
--
-- Note the Planning Time: 12.4ms. PostgreSQL is parsing an IN list
-- with 5,000 elements. The planner itself becomes a bottleneck.
-- WITH idx_comments_post_id:
-- Bitmap Heap Scan on comments (cost=52.17..1089.00 rows=47000 width=128)
-- Recheck Cond: (post_id = ANY('{1,2,3,...,5000}'::integer[]))
-- -> Bitmap Index Scan on idx_comments_post_id
-- Index Cond: (post_id = ANY('{1,2,3,...,5000}'::integer[]))
-- Planning Time: 11.9 ms
-- Execution Time: 0.3 ms The execution time improved 40x with the index — 11.8ms to 0.3ms. But the planning time barely changed: 12.4ms to 11.9ms. PostgreSQL is spending 12ms just parsing and planning a query with 5,000 literal values in the IN list.
This overhead scales superlinearly:
-- How IN clause size affects planning time:
-- (measured with pg_stat_statements on PostgreSQL 16)
--
-- IN list size | Planning time | Execution time
-- -------------|-----------------|----------------
-- 10 | 0.08ms | 0.02ms
-- 100 | 0.31ms | 0.04ms
-- 1,000 | 2.80ms | 0.12ms
-- 5,000 | 12.40ms | 0.30ms
-- 10,000 | 28.10ms | 0.58ms
-- 50,000 | 142.00ms | 2.90ms
--
-- The planning time grows superlinearly with IN list size.
-- At 50,000 IDs, PostgreSQL spends 142ms just PLANNING the query.
-- The actual execution is 2.9ms.
--
-- GORM's Preload sends every parent ID in the IN clause.
-- If your parent query returns 50,000 rows, every Preload pays
-- this planning tax on every request. At 50,000 parent IDs, PostgreSQL spends 142ms planning each Preload query before executing a single row fetch. With five Preloads, that is 710ms of planning time alone. The queries themselves execute in under 15ms total.
The alternative is = ANY($1::int[]) with a parameterized array:
-- The alternative: = ANY($1::int[]) with a parameter
-- Instead of IN (1,2,3,...,5000), send a single array parameter:
SELECT * FROM "comments"
WHERE "comments"."post_id" = ANY($1::int[])
AND "comments"."deleted_at" IS NULL;
-- Planning time: 0.12ms (constant, regardless of array size)
-- Execution time: 0.31ms
--
-- Same result. Same index usage. 100x less planning overhead.
-- But GORM's Preload generates IN (...), not = ANY($1).
-- This is hardcoded in GORM's association processor.
-- There is no configuration to change it.
--
-- pgx sends arrays as binary parameters by default.
-- The planner sees one parameter, not 5,000 literal values. Same result. Same index usage. Planning time drops from 12ms to 0.12ms — a 100x improvement. But GORM's Preload generates IN (...) with literal values, not = ANY($1) with a parameter. This is hardcoded in GORM's association processor. There is no configuration to change it, no hook to intercept it, no plugin that rewrites it.
For most applications with reasonable result set sizes (under 1,000 parents), the planning overhead is negligible — under 3ms per Preload. But if your parent queries return tens of thousands of rows, and you are wondering why your Preloads are slow despite having perfect indexes, this is likely the reason. The execution plan is optimal. The time to create the execution plan is the bottleneck.
The indexes GORM needs but does not create
GORM's AutoMigrate creates tables and adds columns. It does not create the indexes that its own loading strategies depend on. This is the silent performance tax that compounds over time, and it is, in my experience, the single most common cause of slow GORM applications.
-- The indexes GORM's Preload relies on (but does not create):
-- For Preload("Author"):
CREATE INDEX idx_authors_id ON authors (id);
-- Already exists (primary key). No action needed.
-- For Preload("Comments"):
CREATE INDEX idx_comments_post_id ON comments (post_id);
-- GORM AutoMigrate does NOT create this. You must add it yourself.
-- Without it, every Preload("Comments") triggers a sequential scan
-- on the comments table. At 47,000 rows, that is 12ms per call.
-- With the index: 0.3ms.
-- For Preload("Tags") via many2many:
CREATE INDEX idx_post_tags_post_id ON post_tags (post_id);
CREATE INDEX idx_post_tags_tag_id ON post_tags (tag_id);
-- Again, AutoMigrate creates the composite primary key but not
-- the individual indexes Preload uses for its IN clause.
-- For Joins("Author"):
CREATE INDEX idx_posts_author_id ON posts (author_id);
-- The JOIN condition. Without it, PostgreSQL hash-joins
-- the entire posts table against authors on every query. Without idx_comments_post_id, every Preload("Comments") triggers a sequential scan on the comments table. At 47,000 rows, that is 12ms per query. With the index, 0.3ms. A 40x improvement for a single CREATE INDEX statement.
Without idx_posts_author_id, every Joins("Author") forces a hash join against the full posts table. The planner cannot use a nested loop with an index lookup because there is no index to look up.
Covering indexes for Preload
If you want to go further — and for high-traffic endpoints, you should — covering indexes eliminate heap access entirely:
-- For Preload("Comments") that only needs body text:
CREATE INDEX idx_comments_post_id_body ON comments (post_id) INCLUDE (body);
-- A covering index. PostgreSQL satisfies the query from the index alone,
-- never touching the heap. For 47,000 comments, this eliminates ~47,000
-- random I/O operations. The Preload becomes an index-only scan.
-- For the post_tags join table:
CREATE INDEX idx_post_tags_covering ON post_tags (post_id, tag_id);
-- Both columns the Preload needs, in one index.
-- The many2many Preload becomes index-only.
-- Verify with EXPLAIN:
-- EXPLAIN (ANALYZE, BUFFERS) SELECT body FROM comments WHERE post_id = ANY('{1,2,3}');
-- Look for "Index Only Scan" — if you see "Index Scan" followed by
-- heap fetches, the covering index is not being used. Run VACUUM first. A covering index on comments(post_id) INCLUDE (body) turns Preload's query into an index-only scan. PostgreSQL reads the index, finds the matching rows, and returns the body column directly from the index without visiting the table. For 47,000 comments, this eliminates tens of thousands of random I/O operations. The query drops from 0.3ms (with a regular index) to 0.08ms (with a covering index). Marginal in absolute terms, but if this Preload executes 10,000 times per hour, those microseconds become minutes.
How to audit
Audit your foreign key columns. If GORM Preloads or Joins against it, it needs an index. AutoMigrate will not tell you this. EXPLAIN ANALYZE will — look for Seq Scan on tables that should be using Index Scan. Look for Heap Fetches on queries that could be satisfied by a covering index. And run VACUUM before trusting EXPLAIN results — PostgreSQL's visibility map must be current for index-only scans to engage.
Connection pool arithmetic
There is a secondary cost to GORM's multi-query Preload strategy that does not appear in per-query benchmarks: connection hold time.
// GORM connection pool settings that interact with Preload
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
sqlDB, _ := db.DB()
sqlDB.SetMaxOpenConns(25) // Max connections to PostgreSQL
sqlDB.SetMaxIdleConns(10) // Keep 10 warm
sqlDB.SetConnMaxLifetime(time.Hour)
// Why this matters for Preload:
// Each Preload query runs on the SAME connection within a session.
// Five Preloads = five sequential queries on one connection.
// The connection is held for the entire duration.
//
// With 25 max connections and 128ms per Preload chain:
// Maximum throughput = 25 / 0.128 = ~195 requests/second
//
// With raw pgx at 18ms per request:
// Maximum throughput = 25 / 0.018 = ~1,389 requests/second
//
// The connection pool amplifies the performance gap.
// Slower queries hold connections longer.
// Held connections starve other requests.
// Other requests queue. Latency cascades. Each Preload chain holds a connection for the entire duration of its sequential queries. Faster individual queries mean shorter connection hold times, which means higher throughput from the same pool. This is why the 128ms vs 18ms difference matters more than it appears — it is not just 7x faster per request, it is 7x more requests per connection per second.
If you are seeing connection pool exhaustion, timeouts, or queuing in your Go application, the first question is not "do I need more connections?" It is "how long is each connection held?" And the answer, for GORM Preload chains, is often "longer than it needs to be."
Debugging GORM's generated SQL
Before optimizing anything, see what GORM is actually sending. This step is non-negotiable. Assumptions about generated SQL are wrong more often than they are right.
// GORM's debug mode — essential for understanding what it sends
db.Debug().Preload("Author").Preload("Comments").Find(&posts)
// Prints every SQL query to stdout with timing:
// [2.871ms] [rows:5000] SELECT * FROM "posts" WHERE "posts"."deleted_at" IS NULL
// [1.204ms] [rows:487] SELECT * FROM "authors" WHERE "authors"."id" IN (1,2,...,487) AND ...
// [8.731ms] [rows:47000] SELECT * FROM "comments" WHERE "comments"."post_id" IN (1,2,...,5000) AND ...
//
// Always run Debug() during development. Not in production.
// The output reveals patterns you cannot see from the Go side:
// - Which queries are slow
// - Whether Preload batched or degraded to N+1
// - Whether Joins produced duplicates
//
// Alternatively, use a GORM logger that writes to your structured logger:
newLogger := logger.New(log.New(os.Stdout, "", log.LstdFlags), logger.Config{
SlowThreshold: 200 * time.Millisecond,
LogLevel: logger.Warn,
})
db, _ = gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: newLogger}) Run every Preload and Joins chain through Debug() during development. Count the queries. Check whether Preload batched or degraded. Verify that Joins produced a single query, not a JOIN plus a redundant Preload. The debug output is how you catch issue #6834's N+1 regression before it reaches production.
For production monitoring, configure GORM's logger to emit warnings for queries exceeding a threshold. The slow-query log is your early warning system — when a Preload chain that normally completes in 128ms suddenly takes 14 seconds, you want to know immediately, not when users start filing tickets.
An honest accounting: where GORM earns its keep
I have spent considerable time documenting GORM's failure modes, and fairness demands I also document its strengths. A waiter who only delivers bad news is not providing a complete service.
GORM excels at precisely the things this article does not benchmark: rapid development, schema management, and the hundred small CRUD operations that constitute the majority of most application code. db.Create(&post) is one line. The pgx equivalent is fifteen lines of explicit column listing, placeholder numbering, and scan assignments. For an admin panel that handles 50 requests per minute, the performance difference is invisible and the development velocity difference is enormous.
GORM's hook system — BeforeCreate, AfterUpdate, BeforeDelete — provides lifecycle management that raw SQL cannot replicate without building a framework around it. Soft deletes, automatic timestamps, and cascading updates come for free. These are genuinely useful features that would require significant custom code to replicate with pgx.
GORM's transaction management is also cleaner than raw pgx for multi-step operations. db.Transaction(func(tx *gorm.DB) error { ... }) handles begin, commit, and rollback automatically. The pgx equivalent requires explicit transaction management, and forgetting to close a transaction is a connection leak that will exhaust your pool.
The recommendation is not "abandon GORM." It is "understand where GORM adds value and where it adds overhead, and choose your tools accordingly." For most endpoints, GORM is fine. For hot paths, it is not. The skill is knowing which is which — and measuring, rather than guessing.
Where Gold Lapel fits in
GORM generates the SQL. PostgreSQL executes it. Between those two systems, patterns emerge that neither can see on its own.
Gold Lapel sits in that space. It observes the actual query traffic from your Go application — the five Preload SELECTs, the Joins LEFT JOINs, the occasional N+1 regression — and applies optimizations that would otherwise require manual intervention:
- N+1 detection: When Preload degrades to per-row SELECTs (issue #6834), Gold Lapel detects the pattern — thousands of identical parameterized queries from the same connection in rapid succession — and batches them back into a single
INquery before they reach PostgreSQL. The ORM's bug becomes invisible to the database. - IN clause rewriting: Those 5,000-element IN lists that cost 12ms of planning time each? Gold Lapel rewrites them to
= ANY($1::int[])with a binary array parameter. Same result. 100x less planning overhead. GORM's hardcoded IN clause generation stops being a bottleneck. - Auto-indexing: Those missing foreign key indexes that
AutoMigratedoes not create? Gold Lapel identifies theSeq Scanpatterns in Preload'sINqueries and recommends — or creates — the indexes automatically. The 40x improvement onPreload("Comments")happens without a code change or a deployment. - Materialized views for repeated joins: If
Joins("Author")with the same filter runs 10,000 times per hour, Gold Lapel can materialize that join result and serve subsequent queries from the materialized view. The 2ms query becomes a 0.2ms lookup.
Fix the loading strategies you can control. Choose Preload for one-to-many, Joins for singular associations, pgx for hot paths. Add the missing indexes. Test Joins+Preload combinations against your specific GORM version before shipping them.
And for the N+1 patterns that appear despite your best efforts — the regressions, the untested code paths, the associations that grow beyond their original cardinality — let the proxy handle what the ORM cannot anticipate. A well-run household does not rely on every member of staff performing flawlessly at all times. It has systems in place for when they do not.
Frequently asked questions
Terms referenced in this article
The Preload-versus-Joins question is, if I may say so, one chapter of a longer story about ORMs and the queries they generate. I have prepared a broader examination of ORM performance versus raw SQL that may prove illuminating — particularly for those moments when GORM's abstractions grow more costly than convenient.