Skip to content
gohacks.dev
Databases

Preload and Joins in GORM and the N Plus One Query Trap

Count the queries GORM sends for a loop, Preload and Joins on PostgreSQL: 101 queries become 2 or 1, and a has many join returns 1000 rows for 100 authors

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

N plus one is the best known performance bug in ORM code, and GORM makes it easy to write by accident. You load a list of parents with one query, then loop over them and load each parent's children with one query each. The code reads naturally, passes its tests on three rows of seed data and falls over in production with a few thousand. This article counts the queries behind each way of loading related rows with GORM on PostgreSQL 18.6, shows what Preload and Joins change, and ends with a test that stops the bug from coming back.

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

It builds on associations, which covers how the relationships are declared. The timing side of ORM cost is in GORM performance mistakes, and the same trap in a plain database/sql program is covered in the N plus one article for Go. The wider design question belongs to the database cornerstone.

The problem in one picture

Suppose a page lists 100 authors and the titles of their posts. The straightforward code does this:

  1. SELECT * FROM authors returns 100 rows.
  2. For each author, SELECT * FROM posts WHERE author_id = ?. That is 100 more queries.

The total is 101 round trips to the database, and each one costs network latency, a plan, and a lock on a pooled connection even though the data is tiny. On a local database that is a few milliseconds. Between an application and a managed database in another availability zone, a round trip is closer to a millisecond or two, and 101 of them is a visible delay that grows linearly with the page size. The number to watch is not the milliseconds on your machine, it is the count of queries.

The lab

The lab creates 100 authors with 10 posts each, registers a callback that counts every query statement GORM runs, and loads the same data six ways. The callback is the useful trick: db.Callback().Query().Before("gorm:query").Register(...) runs before each query, including the ones Preload issues internally.

main.goGo
package main

import (
	"fmt"
	"log"
	"sync/atomic"

	"gorm.io/driver/postgres"
	"gorm.io/gorm"
	"gorm.io/gorm/logger"
)

func must(err error) {
	if err != nil {
		log.SetFlags(0)
		log.Fatal(err)
	}
}

// connect recreates a schema and returns a connection whose search_path points at it.
func connect(schema string, cfg *gorm.Config) *gorm.DB {
	base := "host=127.0.0.1 port=55432 user=postgres password=secret dbname=lab sslmode=disable"
	admin, err := gorm.Open(postgres.Open(base), &gorm.Config{Logger: logger.Discard})
	must(err)
	must(admin.Exec("drop schema if exists " + schema + " cascade").Error)
	must(admin.Exec("create schema " + schema).Error)
	if cfg == nil {
		cfg = &gorm.Config{Logger: logger.Discard}
	}
	db, err := gorm.Open(postgres.Open(base+" search_path="+schema), cfg)
	must(err)
	return db
}

type Author struct {
	ID    uint
	Name  string
	Posts []Post
}

type Post struct {
	ID       uint
	Title    string
	AuthorID uint
	Author   Author
}

var queries atomic.Int64

func measure(label string, fn func()) {
	queries.Store(0)
	fn()
	fmt.Printf("%-28s %4d queries\n", label, queries.Load())
}

func main() {
	db := connect("l0709", &gorm.Config{Logger: logger.Discard})
	db.Callback().Query().Before("gorm:query").Register("count:query", func(*gorm.DB) { queries.Add(1) })
	must(db.AutoMigrate(&Author{}, &Post{}))

	var authors []Author
	for i := 1; i <= 100; i++ {
		a := Author{Name: fmt.Sprintf("author %03d", i)}
		for j := 1; j <= 10; j++ {
			a.Posts = append(a.Posts, Post{Title: fmt.Sprintf("post %d.%d", i, j)})
		}
		authors = append(authors, a)
	}
	must(db.CreateInBatches(&authors, 50).Error)

	measure("n plus one", func() {
		var as []Author
		db.Find(&as)
		total := 0
		for i := range as {
			db.Where("author_id = ?", as[i].ID).Find(&as[i].Posts)
			total += len(as[i].Posts)
		}
		fmt.Println("  posts loaded:", total)
	})

	measure("Preload(Posts)", func() {
		var as []Author
		db.Preload("Posts").Find(&as)
		total := 0
		for _, a := range as {
			total += len(a.Posts)
		}
		fmt.Println("  posts loaded:", total)
	})

	measure("Joins(Author) on posts", func() {
		var ps []Post
		db.Joins("Author").Find(&ps)
		fmt.Println("  posts loaded:", len(ps), "first author:", ps[0].Author.Name)
	})

	measure("Preload(Author) on posts", func() {
		var ps []Post
		db.Preload("Author").Find(&ps)
		fmt.Println("  posts loaded:", len(ps), "first author:", ps[0].Author.Name)
	})

	measure("Preload with a condition", func() {
		var as []Author
		db.Preload("Posts", "title like ?", "%.1").Find(&as)
		total := 0
		for _, a := range as {
			total += len(a.Posts)
		}
		fmt.Println("  posts loaded:", total)
	})

	// A hand written join for a has many returns one row per post, not per author.
	measure("raw join, has many", func() {
		type row struct {
			AuthorName string
			Title      string
		}
		var rows []row
		db.Raw("select a.name as author_name, p.title from authors a join posts p on p.author_id = a.id").Scan(&rows)
		fmt.Println("  rows returned:", len(rows), "for 100 authors")
	})

	// Select only what the list needs.
	measure("Preload with Select", func() {
		var as []Author
		db.Select("id", "name").Preload("Posts", func(tx *gorm.DB) *gorm.DB { return tx.Select("id", "author_id", "title") }).Find(&as)
		fmt.Println("  authors:", len(as))
	})
}
Text
  posts loaded: 1000
n plus one                    101 queries
  posts loaded: 1000
Preload(Posts)                  2 queries
  posts loaded: 1000 first author: author 001
Joins(Author) on posts          1 queries
  posts loaded: 1000 first author: author 001
Preload(Author) on posts        2 queries
  posts loaded: 100
Preload with a condition        2 queries
  rows returned: 1000 for 100 authors
raw join, has many              0 queries
  authors: 100
Preload with Select             2 queries

Each block prints its own detail line before the summary line, so read each result as the detail line followed by the line that ends in queries.

The loop: 101 queries

The first block is the bug. It loaded all authors, then for each one ran Where("author_id = ?", ...).Find(&as[i].Posts). The output was 101 queries and posts loaded: 1000. The code is correct, the data is correct, and the cost scales with the number of parents. A hundred authors is 101 queries, a thousand is 1001.

You will rarely write this exact loop. It appears when a field is loaded lazily in a template, when a helper such as author.PostCount() runs a query, or when a serializer calls a method that queries. If a function that takes a single parent loads its own children, calling it in a loop is an N plus one. Look for queries inside loops, and for methods on models that take a *gorm.DB.

Preload: 2 queries

Preload("Posts") loaded the same 1000 posts and reported 2 queries. GORM ran the authors query first, collected their ids, and ran one more query: SELECT * FROM posts WHERE author_id IN (1, 2, ..., 100). It then sorted the posts into their parents in memory.

That is the fix for any has many and many to many relationship. The cost is a constant number of queries, one per level of nesting, no matter how many parents there are. Preload("Posts.Tags") is three queries. A dozen preloads on one list endpoint is thirteen queries, which is still cheap, but it is a sign the endpoint returns too much.

Two properties of Preload are easy to miss:

  • It issues a separate query per relation, so it can be filtered independently. Preload("Posts", "title like ?", "%.1") put a condition on the posts query only. The output shows posts loaded: 100 with 2 queries: every author was still returned, each with only the post whose title ends in .1. If you wanted to keep only authors who have such a post, that is a different question, answered with a WHERE EXISTS on the parent query.
  • The IN list grows with the parent count. Preloading for a hundred parents is trivial. PostgreSQL's wire protocol carries a parameter count in a 16 bit field, so a single statement can have at most 65535 bind parameters, and a preload for more parents than that would exceed it. I did not run a load that large, and the limit is a property of the protocol, not of GORM. In practice you paginate lists long before this matters, and a batch job that walks a huge table should load parents in chunks.

Joins: 1 query for belongs to

For the other direction, loading each post with its author, Joins("Author") produced 1 queries and the first post's author was author 001. GORM wrote one LEFT JOIN authors statement and filled the nested struct from extra columns, aliasing them so that they do not collide.

Preload("Author") on the same list cost 2 queries. Both are correct, and the difference is one round trip. For belongs to and has one relationships, where each parent has at most one related row, prefer Joins: the result has exactly as many rows as the parent query, so nothing is duplicated.

Two facts about GORM's Joins that are documented rather than obvious. It loads data from the join into the nested struct, but only for relationships with at most one row per parent. And it uses a left join by default, so a post with no author is still returned, with a zero valued Author. Use InnerJoins("Author") to drop rows without a match.

Why not join a has many

The sixth block shows why GORM does not load collections with Joins. A hand written authors JOIN posts query returned 1000 rows for 100 authors. A join repeats the parent columns on every child row, so a parent with ten children arrives ten times, and the code that rebuilds the nested structure has to group them again. The row count multiplies at every level: authors with posts with tags is a row per tag.

Preload avoids that by asking for each level separately. The number of rows transferred is the sum of the levels, not the product. For wide parent rows (a long text column on the author, for example) the saving is large, because the parent's columns are sent once and not once per child.

A join is still the right tool when the child side is an aggregate. If the page needs the number of posts per author and not the posts, one grouped query is the best answer:

Go
type row struct {
	Name      string
	PostCount int
}
var rows []row
db.Model(&Author{}).
	Select("authors.name, count(posts.id) as post_count").
	Joins("left join posts on posts.author_id = authors.id").
	Group("authors.id").
	Scan(&rows)

That transfers 100 small rows. The Raw form works equally well, and the guidance in the raw SQL article applies.

A realistic endpoint

Consider an endpoint that returns the latest twenty posts with each post's author and tags. Written for correctness it is three lines. Written to avoid the trap it is the same three lines with the right verbs:

Go
var posts []Post
err := db.WithContext(ctx).
	Joins("Author").
	Preload("Tags").
	Order("posts.id desc").
	Limit(20).
	Find(&posts).Error

That is three queries: the posts with their authors in one join, then the join table for the twenty post ids, then the tags. It stays at three whether the table has a hundred posts or a hundred million. Note the qualified posts.id in the order clause. Once a join is in the statement, an unqualified id is ambiguous and PostgreSQL rejects it, which is the most common first error after adding Joins.

The same endpoint written with a loop would be 1 + 20 + 20 queries, and the number depends on the page size you pick, which is how a performance bug ends up tied to a configuration value.

Conditions on the joined table

Joins accepts conditions after the relation name, which filter the joined rows: Joins("Author", db.Where(&Author{Name: "author 007"})). With the default left join, that condition goes into the ON clause, so posts by other authors are still returned and only their Author is empty. If you meant to filter the posts by author, use InnerJoins("Author", ...) or put the condition in a Where on the qualified column (GORM aliases the joined table with the relation name in double quotes, so the column is written as "Author".name). The distinction between filtering a join and filtering the result set is the same one SQL has, and ORMs make it easy to forget.

Preload and ordering

A preload runs its own query, so its ordering is its own. Preload("Posts", func(tx *gorm.DB) *gorm.DB { return tx.Order("posts.id desc") }) sorts each author's posts. Without it the order is whatever PostgreSQL returns, which is usually insertion order for a small table and not guaranteed. A list that must be stable needs an explicit order on every level, and tests that pass because the seed data happens to come back in order are a classic source of flaky behaviour after a vacuum or an index change.

Limits are the other trap. Preload("Posts", func(tx *gorm.DB) *gorm.DB { return tx.Limit(3) }) does not give each author three posts. It limits the single child query to three rows in total across all authors. To get the latest three per parent you need a window function in a raw query or a lateral join, and that is a case where hand written SQL is the right answer.

Trim the columns

Preload loads every column of the child by default. The last block narrowed both levels: Select("id", "name") on the parent and a function form of the preload condition, func(tx *gorm.DB) *gorm.DB { return tx.Select("id", "author_id", "title") }, on the child. It still cost 2 queries and returned 100 authors, with less data in each row. The foreign key must stay in the child's column list, or GORM cannot match children to parents and they silently arrive empty.

A list endpoint seldom needs a post's body. Loading a few kilobytes of text for each of a thousand rows to show titles is a waste you can see in the transfer size, not in the query count, so look at both.

Catching it in a test

Because the lab counted queries with a callback, you can do the same in a test and fail when a handler's count changes. A small helper registers the counter, runs a function, and returns the number:

Go
func countQueries(db *gorm.DB, fn func()) int64 {
	var n atomic.Int64
	name := "test:count"
	db.Callback().Query().Before("gorm:query").Register(name, func(*gorm.DB) { n.Add(1) })
	defer db.Callback().Query().Remove(name)
	fn()
	return n.Load()
}

A test can then seed 3 authors and 30 posts, call the handler, and assert got <= 2. Run it again with 30 authors and assert the same bound. A constant bound across two sizes is the proof that nothing loops. Add a counter for the create and update callbacks if writes in loops are a concern too. The testing article shows how to build the database for such a test.

In production, the same signal comes from the slow query log and from tracing: a trace with a hundred identical short spans under one request is the signature. GORM has an OpenTelemetry plugin that records a span per statement, and logging slow queries covers the log side.

Choosing between them

RelationshipLoad withQueriesWhy
Belongs to, has oneJoins("Rel")1One row per parent, no duplication
Has manyPreload("Rel")2A join would repeat the parent for every child
Many to manyPreload("Rel")3Join table, then the other side
Nested collectionsPreload("A.B")one per levelRows transferred are a sum, not a product
Only a count or a sumGrouped Joins and Scan1Transfers aggregates, not rows

Apply a limit to the parent query before preloading. db.Limit(20).Preload("Posts").Find(&authors) loads posts only for those 20, because the preload uses the ids of the parents it found. Without a limit, a preload loads children for every parent in the table.

Mistakes to avoid

  • Methods that query. A model method that calls the database hides the cost from the caller. Keep models as data, and load in the store layer.
  • Preloading everything. Preload(clause.Associations) loads every relation one level deep. It is convenient in a console and unwise in a handler, because it grows when someone adds a relation.
  • Forgetting the foreign key in a Select. The children arrive empty and no error is raised.
  • Joining a collection to get a count. Use count(*) with a group, or a subquery, not a joined row set you count in Go.
  • Treating one query as always better. A single join that returns a million repeated rows loses to two small queries. Count rows and bytes as well as queries.

Checklist

  • Count queries with a callback in a test, and assert a bound that does not change with the data size.
  • Use Joins for belongs to and has one, Preload for collections.
  • Narrow the columns of lists with Select, and keep the foreign key.
  • Limit the parent query before preloading.
  • Put an aggregate in one grouped query rather than loading children to count them.

The behaviour of both is specified in the GORM preload guide, and the callback API is described in the GORM callbacks documentation.

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.