GORM is a powerful ORM tool that makes Go development much easier and faster. It’s basically the most popular ORM in Go. But like any abstraction or encapsulation, we need to be careful when using it. Misusing GORM can lead to unwanted errors or performance issues.
In this post, I’m going to cover some common mistakes developers make when using GORM and how to avoid them.
1. Counting in a Loop
GORM’s Count()
method lets you count items in a table, but sometimes developers count each item separately inside a loop — which is a performance killer.
Example:
for _, u := range users {
var count int64
db.Model(&Order{}).Where("user_id = ?", u.ID).Count(&count)
}
This generates multiple queries like:
SELECT count(*) FROM "orders" WHERE user_id = 1;
SELECT count(*) FROM "orders" WHERE user_id = 2;
...
Many queries and lots of DB requests = slow performance.
Better way? Use GROUP BY!
var counts []struct {
UserID uint
Total int64
}
db.Model(&Order{}).
Select("user_id, COUNT(*) as total").
Group("user_id").
Scan(&counts)
This generates:
SELECT user_id, COUNT(*) as total FROM "orders" GROUP BY "user_id";
Just one query for all counts—much faster!
2. N+1 Problem Without Preload
GORM supports associations, but loading related data incorrectly causes the notorious N+1 query problem.
Example:
var users []User
db.Find(&users)
for i := range users {
db.Model(&users[i]).Association("Orders").Find(&users[i].Orders)
}
This produces queries like:
SELECT * FROM "users";
SELECT * FROM "orders" WHERE "orders"."user_id" = 1;
SELECT * FROM "orders" WHERE "orders"."user_id" = 2;
...
Lots of queries again!
Fix it with Preload:
var users []User
db.Preload("Orders").Find(&users)
SQL generated:
SELECT * FROM "users";
SELECT * FROM "orders" WHERE "orders"."user_id" IN (1, 2, 3, ...);
Much more efficient!
3. Using Find
for Single Rows Instead of First
or Take
GORM has multiple ways to fetch data: Find()
, First()
, Take()
, Scan()
, etc. Choosing the wrong one can lead to inefficiency.
Example:
var user User
db.Where("code = ?", 1432).Find(&user)
This fetches all users with code 1432.
Query:
SELECT * FROM "users" WHERE code = 1432;
If you only need one user, use:
db.Where("code = ?", 1432).First(&user)
which generates:
SELECT * FROM "users" WHERE code = 1432 ORDER BY "id" LIMIT 1;
Returns just one row—better for performance.
What about Take()
?
First()
adds an ORDER BY
clause on the primary key, while Take()
does not. So Take()
is good if you want any record without ordering.
4. Using Offset for Large Pages
Paging is usually done with Offset
and Limit
. But using large offsets causes performance issues because the database has to scan and skip many rows.
Example:
db.Offset(100000).Limit(20).Find(&orders)
SQL:
SELECT * FROM "orders" LIMIT 20 OFFSET 100000;
This makes the DB scan 100,000 rows just to skip them.
Better approach: use the "LastID" or keyset pagination.
lastID := 500000
db.Where("id > ?", lastID).Limit(20).Find(&orders)
SQL:
SELECT * FROM "orders" WHERE id > 500000 LIMIT 20;
Much faster!
5. Using Save
for Simple Field Updates
Save()
updates all fields in the struct, even if you only want to update one field.
Example:
user.Name = "NewName"
db.Save(&user)
Generates:
UPDATE "users" SET "id"=1, "name"='NewName', "email"='x', "password"='y', ...
WHERE "id" = 1;
This updates indexed fields unnecessarily, slowing down the DB.
Better way:
Update a single field:
db.Model(&user).Update("name", "NewName")
Or multiple fields:
db.Model(&user).Updates(map[string]interface{}{"name": "x"})
6. Forgetting Select
When Using Joins
If you don’t specify Select
, GORM defaults to SELECT *
, which loads unnecessary data and big payloads.
Example:
db.Joins("JOIN profiles ON profiles.user_id = users.id").Find(&users)
Generates:
SELECT * FROM "users" JOIN profiles ON profiles.user_id = users.id;
Instead, specify needed fields:
db.Select("users.id", "users.name", "profiles.avatar").
Joins("JOIN profiles ON profiles.user_id = users.id").
Find(&users)
7. Forgetting to Use Omit
on Large Struct Updates and Zero Value Update Errors
Go doesn’t have nullable types, so updating a struct with missing fields can overwrite existing fields with zero values.
Example:
user := User{ID: 1, Name: "NewName"}
db.Save(&user)
Generates:
UPDATE "users" SET "id"=1, "name"='NewName', "email"='', "password"='', ...
WHERE "id" = 1;
You lose data because empty fields overwrite existing values.
Fix: use Omit
to skip fields or use Updates
with a map.
db.Model(&user).Omit("email", "password").Updates(User{Name: "NewName"})
Or:
db.Model(&user).Updates(map[string]interface{}{"name": "x"})
8. Creating Objects in Loop Without CreateInBatches
Many times, you need to insert a list of data. Running Create()
inside a loop sends many DB requests—slow!
Example:
for _, u := range users {
db.Create(&u)
}
Better approach:
db.CreateInBatches(users, 100)
Which sends a single query inserting many records:
INSERT INTO users (...) VALUES (...), (...), ...;
9. Record Not Found Error
GORM returns a specific error for "record not found" when using First()
or Take()
. Some developers treat this as a general error and pre-check existence unnecessarily.
Better way:
var user User
result := db.First(&user, 1)
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
// handle missing record, e.g. return 404
}
10. Pointer vs Non-Pointer Model Slices Affect Query Behavior
When scanning multiple rows into slices, you can use:
[]User
(slice of structs) or[]*User
(slice of pointers)
Why does it matter?
[]User
copies each row into a new struct.[]*User
creates pointers to structs.
Pointers are more flexible (can be nil
, can modify in-place) and expected by some GORM features or libraries.
Example:
var users []User
db.Preload("Orders").Find(&users)
GORM internally converts pointers but it causes overhead.
Better:
var users []*User
db.Preload("Orders").Find(&users)
Performance tradeoff: copying large structs vs. many small pointers and more GC pressure.
Conclusion
GORM is a really powerful ORM. It might not have all the fancy features of .NET Entity Framework or Django ORM, but it covers many backend use cases well, making code clean and readable with less effort.
That said, it’s important to understand how GORM works under the hood to avoid inefficiencies and unexpected behaviors. Personally, I prefer raw SQL for many cases because ORMs can generate inefficient queries and become bottlenecks in performance.
ORMs have their place, but developers often use them by default without fully thinking about their use cases or inner workings.
Thanks for reading! I’d love to hear your thoughts and experiences with GORM.