GORM Performance Mistakes That Hurt Services at Scale
Seven GORM performance mistakes measured on PostgreSQL: wide selects 4.3x, default transactions 2.0x, a loop of updates 75x, each with the numbers and the fix

GORM is rarely the slow part of a service. The database and the number of round trips are. But GORM makes certain slow patterns easy to write, and each one is a habit that scales badly. This article lists seven, measures each on the same table, and gives the fix. The table has 20000 rows with a 4000 byte body, on PostgreSQL 18.6 in Docker on the same machine as the program, so every difference below comes from work done, not from the network. Real deployments add network latency to each round trip, which makes the round trip mistakes worse than shown.
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)
The numbers are the best of several runs of one program, and the ratio is more reliable than the absolute value. This follows the GORM tutorial, pairs with Preload and Joins for the N plus one problem and batch insert for write throughput, and sits in the performance cluster under the Go performance cornerstone.
The lab
Each block compares a mistake with its fix on the docs table (20000 rows, 100 owners, a 4000 character body) and prints both times and the ratio:
package main
import (
"fmt"
"log"
"strings"
"time"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
type Doc struct {
ID uint
Owner int `gorm:"index"`
Title string
Body string
Hits int
}
const base = "host=127.0.0.1 port=55432 user=postgres password=secret dbname=lab sslmode=disable"
func open(schema string, cfg *gorm.Config) *gorm.DB {
db, err := gorm.Open(postgres.Open(base+" search_path="+schema), cfg)
if err != nil {
log.Fatal(err)
}
return db
}
// best returns the fastest of several runs, which removes most scheduling noise.
func best(runs int, fn func()) time.Duration {
min := time.Hour
for i := 0; i < runs; i++ {
start := time.Now()
fn()
if d := time.Since(start); d < min {
min = d
}
}
return min
}
func report(label string, a, b time.Duration) {
fmt.Printf("%-44s %9s vs %9s (%.1fx)\n", label, a.Round(time.Microsecond), b.Round(time.Microsecond), float64(a)/float64(b))
}
func main() {
log.SetFlags(0)
quiet := &gorm.Config{Logger: logger.Discard}
admin := open("public", quiet)
admin.Exec("drop schema if exists l0726 cascade")
admin.Exec("create schema l0726")
db := open("l0726", quiet)
if err := db.AutoMigrate(&Doc{}); err != nil {
log.Fatal(err)
}
db.Exec(`insert into docs (owner, title, body, hits)
select g % 100, 'doc ' || g, repeat('x', 4000), 0 from generate_series(1, 20000) g`)
db.Exec("analyze docs")
// 1. Wide rows: every column versus the two the page needs.
var all []Doc
var slim []Doc
full := best(5, func() { db.Where("owner = ?", 7).Find(&all) })
part := best(5, func() { db.Select("id", "title").Where("owner = ?", 7).Find(&slim) })
report("1. Find all columns / Select two (200 rows)", full, part)
// 2. Count by loading everything.
viaFind := best(5, func() { var d []Doc; db.Select("id").Where("owner = ?", 7).Find(&d); _ = len(d) })
viaCount := best(5, func() { var n int64; db.Model(&Doc{}).Where("owner = ?", 7).Count(&n) })
report("2. Find ids and len / Count", viaFind, viaCount)
// 3. Save writes every column, Update writes one.
var one Doc
db.First(&one, 1)
viaSave := best(20, func() { one.Hits++; db.Save(&one) })
viaUpdate := best(20, func() { db.Model(&one).Update("hits", gorm.Expr("hits + 1")) })
report("3. Save (4000 byte body) / Update one column", viaSave, viaUpdate)
// 4. The implicit transaction on every write.
withTx := best(20, func() { db.Model(&Doc{}).Where("id = 2").Update("hits", 1) })
skip := open("l0726", &gorm.Config{Logger: logger.Discard, SkipDefaultTransaction: true})
noTx := best(20, func() { skip.Model(&Doc{}).Where("id = 2").Update("hits", 1) })
report("4. Update with default tx / SkipDefaultTransaction", withTx, noTx)
// 5. Prepared statement cache for a hot lookup.
prep := open("l0726", &gorm.Config{Logger: logger.Discard, PrepareStmt: true})
plain := best(5, func() {
for i := 0; i < 500; i++ {
var d Doc
db.Select("id", "title").Take(&d, i+1)
}
})
cached := best(5, func() {
for i := 0; i < 500; i++ {
var d Doc
prep.Select("id", "title").Take(&d, i+1)
}
})
report("5. 500 Take calls plain / PrepareStmt", plain, cached)
// 6. Pluck versus building structs for one column.
viaStructs := best(5, func() {
var d []Doc
db.Select("title").Where("owner < ?", 50).Find(&d)
var out []string
for _, x := range d {
out = append(out, x.Title)
}
})
viaPluck := best(5, func() { var out []string; db.Model(&Doc{}).Where("owner < ?", 50).Pluck("title", &out) })
report("6. Find structs for one column / Pluck (10000)", viaStructs, viaPluck)
// 7. Many single-row updates versus one statement with IN.
ids := make([]string, 0, 200)
for i := 1; i <= 200; i++ {
ids = append(ids, fmt.Sprint(i))
}
loop := best(3, func() {
for i := 1; i <= 200; i++ {
db.Model(&Doc{}).Where("id = ?", i).Update("hits", 5)
}
})
inOne := best(3, func() { db.Model(&Doc{}).Where("id in ("+strings.Join(ids, ",")+")").Update("hits", 5) })
report("7. 200 single updates / one UPDATE ... IN", loop, inOne)
}1. Find all columns / Select two (200 rows) 1.578ms vs 364µs (4.3x)
2. Find ids and len / Count 590µs vs 316µs (1.9x)
3. Save (4000 byte body) / Update one column 758µs vs 640µs (1.2x)
4. Update with default tx / SkipDefaultTransaction 603µs vs 301µs (2.0x)
5. 500 Take calls plain / PrepareStmt 117.496ms vs 114.742ms (1.0x)
6. Find structs for one column / Pluck (10000) 3.648ms vs 2.228ms (1.6x)
7. 200 single updates / one UPDATE ... IN 146.479ms vs 1.947ms (75.2x)1. Loading columns you do not use
db.Where("owner = ?", 7).Find(&docs) loads every column of 200 rows, including a 4 KB body: 1.578 ms. Select("id", "title") loads the two a list page needs: 364 µs, 4.3 times faster. The query is the same, the rows are the same, and the difference is the 800 KB of body text that the first version moves from PostgreSQL to the driver and into Go strings.
On a local connection that costs milliseconds. Across a network the transfer time dominates, and memory use follows: each request holds the bodies until it finishes. List endpoints should select the columns they render. Use a separate small struct for list rows (a DocSummary with ID and Title, mapped with Table("docs") or Model(&Doc{})) so the shape is explicit and a new wide column cannot sneak into every list.
2. Counting by loading
Find the ids and call len took 590 µs, and Count took 316 µs. The ratio is modest here because the match is only 200 rows. The first version scales with the number of matches (it transfers and decodes each id), while SELECT count(*) returns one row regardless. On a filter that matches 200000 rows the gap is orders of magnitude. If you need the total and the page, run Count once and Find with Limit, as in the pagination article.
3. Save instead of Update
Save(&doc) wrote every column, including the 4 KB body: 758 µs. Update of a single column with an expression: 640 µs. A 1.2 times difference is small, and it is the least important reason to avoid Save. The real problem is that Save writes back every field you loaded, so a concurrent change to another column is overwritten with stale data. See locking for the lost update. Use Update, Updates with a map or gorm.Expr for partial changes. Reserve Save for when you hold the full, current struct.
4. The default transaction
GORM wraps each write in a transaction. A single-row Update took 603 µs by default and 301 µs with SkipDefaultTransaction: true: twice as fast. The wrapper costs BEGIN and COMMIT round trips around every statement. A loop of inserts shows the same effect at a larger scale: 10000 Create calls took 8.29 s by default and 4.53 s with the setting in the batch article.
Turn it off for write paths that are a single statement and do not use hooks or associations that rely on the atomicity. Turn it on, or wrap the unit of work in your own Transaction, when you write several rows that must succeed together. Setting it on a session (db.Session(&gorm.Session{SkipDefaultTransaction: true})) limits the change to the code that opts in.
5. Prepared statements
PrepareStmt: true caches a prepared statement per connection. Five hundred Take calls took 117.5 ms plain and 114.7 ms with it, a 1.0 times difference that is inside run to run noise. On a local server with a cheap query, parsing is not the cost. The setting may help with complex statements and remote servers, and it has costs: memory per connection, and incompatibility with PgBouncer in transaction mode (pgx and PgBouncer). Because pgx already caches statements by default through its own mechanism, there is little to gain on PostgreSQL. Measure on your workload before enabling, and do not copy it from a tutorial.
6. Structs for one column
Loading a list of titles by finding full structs and copying the field took 3.648 ms for 10000 rows. Pluck("title", &out) took 2.228 ms, 1.6 times faster, because GORM scans straight into the slice without allocating a struct per row. Use Pluck for a list of ids or names, and Scan into a small struct when you need a few columns.
7. Updates in a loop
Updating 200 rows one statement at a time took 146.479 ms. One UPDATE ... WHERE id IN (...) took 1.947 ms, 75 times less. The loop pays a round trip, an implicit transaction and a plan for each row. When every row gets the same new value, or a value computed in SQL, one statement is the answer. When each row gets a different value, a single UPDATE ... FROM (VALUES ...) through Exec, or an upsert with DoUpdates (batch), is still one round trip.
This one grows with latency. With 1 ms to the database instead of 0.7 ms of local overhead, the loop pushes towards 200 ms, and with a cross region database it is seconds.
What the table does not show
Three costs are outside this benchmark, and they matter more in production:
- N plus one. The biggest mistake with relations is a query per parent. It is measured in Preload and Joins: 101 queries against 2.
- Pool exhaustion. Slow handlers hold connections. See pool settings for what 50 goroutines do to a pool of 5.
- Missing indexes. None of the ORM level changes help a query that scans the table. Run
EXPLAINon the statements your slow log shows (guide). Theownerindex in this lab is the reason queries 1 and 2 are fast at all.
Reading these numbers honestly
Three cautions apply to every benchmark in this article. The program and the database share a machine, so there is no network cost and the round trip mistakes (4, 7) look milder than they are in a deployment. The table fits in memory, so reads never touch disk, which flatters the wide select (1). And each timing is the fastest of a few runs, so it shows what the code can do and not what a busy service will see at p99.
The direction of each result does not depend on the machine: sending less data, making fewer round trips and letting the database do arithmetic are faster everywhere. The size of the gap does. Before you change a working service because of a ratio here, reproduce it on your own schema with the slow query log on, and fix the statement that shows up there first. A mistake that costs 300 microseconds on a path nobody calls is not worth a code review comment, and one that costs 150 milliseconds on the home page is.
How to find your own
- Turn on slow query logging at the threshold you care about (logger).
- Count queries per request in a test, as in the Preload article, and assert a bound.
- Profile the service (CPU profile with pprof) and look for reflection and allocation under GORM calls, which show up when a handler loads and decodes thousands of rows.
- Compare the plan and the row count, not only the timing.
Checklist
- Select only the columns a list renders.
- Use
CountandPluckinstead of loading rows to count or collect. - Prefer
Updatewith a column or expression toSavefor partial changes. - Skip the default transaction on single statement writes that do not need it.
- Do not enable
PrepareStmtwithout measuring. - Replace loops of writes with one statement or a batch.
The options are described in the GORM config guide, and the performance notes in the GORM performance guide.


