Why I Stopped Using ORMs in Go (And You Should Too)
Look, I get it. When you’re starting a new Go project, reaching for GORM feels like the smart move. It’s popular, it’s got great docs, and…
Why I Stopped Using ORMs in Go (And You Should Too)

golang sqlc x GORM
Look, I get it. When you’re starting a new Go project, reaching for GORM feels like the smart move. It’s popular, it’s got great docs, and writing db.Where("email = ?", email).First(&user) feels so much cleaner than wrestling with SQL strings.
But here’s the thing: six months into production, I ripped GORM out of our codebase and never looked back.
This isn’t some anti-ORM rant from a SQL purist. I loved ORMs in other languages. But Go is different, and after hitting the same walls over and over, I realized ORMs fight against what makes Go great.
Let me show you what I mean.
Why ORMs Seemed Like the Right Choice
Here’s what our code looked like with GORM. Beautiful, right?
type User struct {
gorm.Model
Email string `gorm:"uniqueIndex"`
Name string
Posts []Post `gorm:"foreignKey:UserID"`
}
func GetUserWithPosts(db *gorm.DB, email string) (*User, error) {
var user User
err := db.Preload("Posts").Where("email = ?", email).First(&user).Error
return &user, err
}
Clean, readable, and it works.
Then Reality Hit
Problem 1: The N+1 Query Nightmare
We had a dashboard that showed users and their post counts. Looks like a simple feature.
func GetAllUsersWithPostCounts(db *gorm.DB) ([]UserWithCount, error) {
var users []User
db.Find(&users)
var result []UserWithCount
for _, user := range users {
var count int64
db.Model(&Post{}).Where("user_id = ?", user.ID).Count(&count)
result = append(result, UserWithCount{User: user, PostCount: count})
}
return result, nil
}
This hit the database 101 times for 100 users. Sure, I could use Preload, but that loaded all posts into memory. For a simple count? Come on.
Problem 2: Type Safety? What Type Safety?
Check out this code that passed all our tests:
func FindActiveUsers(db *gorm.DB) ([]User, error) {
var users []User
err := db.Where("is_active = ?", true).Find(&users).Error
return users, err
}
Looks fine, right? Except we renamed is_active to status two weeks ago. The code compiled. Tests passed. Then production returned zero users. No error, no warning, just silent failure.
Problem 3: The Performance Black Box
Our API started slowing down. I added logging to see the queries:
SELECT * FROM users WHERE email = 'john@example.com';
SELECT * FROM posts WHERE user_id = 42;
SELECT * FROM comments WHERE post_id IN (1,2,3,4,5,6,7,8,9,10);
SELECT * FROM users WHERE id IN (15,16,17,18,19,20);
Wait, why is it selecting from users AGAIN? Turns out GORM was eager-loading comment authors. I didn’t ask for that. Couldn’t even see it in the code.
The Turning Point: Raw SQL + sqlc
While investigating performance optimization strategies, I came across sqlc. At first, writing raw SQL seemed like a step backward. But the approach turned out to be exactly what we needed.
Here’s the magic: you write actual SQL, and sqlc generates type-safe Go code for you.
First, you write your queries in .sql files:
-- queries/users.sql
-- name: GetUserByEmail :one
SELECT id, email, name, created_at
FROM users
WHERE email = $1;
-- name: GetUsersWithPostCounts :many
SELECT
u.id,
u.email,
u.name,
COUNT(p.id) as post_count
FROM users u
LEFT JOIN posts p ON p.user_id = u.id
GROUP BY u.id, u.email, u.name;
-- name: CreateUser :one
INSERT INTO users (email, name, created_at)
VALUES ($1, $2, $3)
RETURNING id, email, name, created_at;
Then sqlc generates this:
// This is auto-generated by sqlc
type User struct {
ID int64
Email string
Name string
CreatedAt time.Time
}
type GetUsersWithPostCountsRow struct {
ID int64
Email string
Name string
PostCount int64
}
func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error) {
// Generated implementation
}
func (q *Queries) GetUsersWithPostCounts(ctx context.Context) ([]GetUsersWithPostCountsRow, error) {
// Generated implementation
}
Now Look At Our Code
Remember that N+1 nightmare? Here’s the fix:
func GetUsersWithPostCounts(ctx context.Context, q *db.Queries) ([]db.GetUsersWithPostCountsRow, error) {
return q.GetUsersWithPostCounts(ctx)
}
One query. One database round-trip. Fully type-safe. If I rename a column, my code won’t compile. No surprises.
Want to see something even better? Complex joins:
-- name: GetUserDashboard :one
SELECT
u.id,
u.email,
u.name,
COUNT(DISTINCT p.id) as total_posts,
COUNT(DISTINCT c.id) as total_comments,
MAX(p.created_at) as last_post_date
FROM users u
LEFT JOIN posts p ON p.user_id = u.id
LEFT JOIN comments c ON c.post_id = p.id
WHERE u.id = $1
GROUP BY u.id, u.email, u.name;
Try doing that elegantly with GORM. I’ll wait.
When You Actually Need Flexibility
Here’s where it gets interesting. Sometimes you need dynamic queries. With GORM, you’d do:
query := db.Model(&User{})
if email != "" {
query = query.Where("email = ?", email)
}
if isActive {
query = query.Where("status = ?", "active")
}
query.Find(&users)
With sqlc and raw SQL? You have options. For simple cases, I just write separate queries:
sql
-- name: ListAllUsers :many
SELECT * FROM users;
-- name: ListActiveUsers :many
SELECT * FROM users WHERE status = 'active';
-- name: ListUsersByEmail :many
SELECT * FROM users WHERE email = $1;
For truly dynamic stuff, I use squirrel or goqu to build SQL, then execute it with database/sql:
import sq "github.com/Masterminds/squirrel"
func FindUsers(ctx context.Context, db *sql.DB, filters UserFilters) ([]User, error) {
query := sq.Select("id", "email", "name").From("users")
if filters.Email != "" {
query = query.Where(sq.Eq{"email": filters.Email})
}
if filters.IsActive {
query = query.Where(sq.Eq{"status": "active"})
}
sql, args, _ := query.ToSql()
rows, err := db.QueryContext(ctx, sql, args...)
// ... scan rows
}
You get type safety where it matters and flexibility where you need it.
The Performance Difference
Real numbers from our production API:
GORM version (list users with post counts):
- Queries: 101 (1 + N)
- Time: 450ms
- Memory: 15MB
sqlc version (same endpoint):
- Queries: 1
- Time: 12ms
- Memory: 800KB
Not even close.
“But ORMs Are Easier!”
Are they though? Let’s be honest:
With GORM, you need to learn:
- GORM’s query syntax
- GORM’s associations and preloading
- GORM’s hooks and callbacks
- SQL (because GORM doesn’t save you when things break)
With sqlc, you need to learn:
- SQL
Which one sounds simpler?
When ORMs Still Make Sense
Look, I’m not dogmatic about this. There are cases where ORMs shine in Go:
- Admin panels and CRUD tools where performance doesn’t matter
- Prototyping when you’re iterating fast
- Small projects where you’re the only developer
But for production APIs? For anything that needs to scale? Just write SQL.
Making the Switch
If you’re convinced, here’s how to migrate:
- Install sqlc:
bash
go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest
- Create
sqlc.yaml:
yaml
version: "2"
sql:
- schema: "schema.sql"
queries: "queries/"
engine: "postgresql"
gen:
go:
emit_interface: true
out: "db"
package: "db"
- Start with new features. Don’t rewrite everything at once.
- Gradually replace hot paths where performance matters.
- Use database migrations (I like
golang-migrate) to manage your schema.
The Bottom Line
ORMs promise to make database access easy. But in Go, they:
- Hide performance problems
- Sacrifice type safety
- Add unnecessary complexity
- Fight against the language’s philosophy
SQL with sqlc gives you:
- Complete control over your queries
- Compile-time type safety (rename a column, your code won’t compile)
- Obvious performance (you can see exactly what queries run)
- Less magic, more clarity
Go is all about simplicity and explicitness. Raw SQL fits that philosophy way better than ORMs ever will.
Your database is powerful. SQL is powerful. Stop hiding them behind an abstraction that makes both worse.
메타데이터
- post_id
- 4d10db1850fa
- slug
- why-i-stopped-using-orms-in-go-and-you-should-too-4d10db1850fa
- url
- https://medium.com/@codexplorer/why-i-stopped-using-orms-in-go-and-you-should-too-4d10db1850fa
- canonical_url
- https://medium.com/@codexplorer/why-i-stopped-using-orms-in-go-and-you-should-too-4d10db1850fa
- author_url
- https://medium.com/@codexplorer
- status
- ok
- fetched_at
- 2026-07-17 00:53:25