← Back to list

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…

Codexplorer · 2025-10-06 18:44 · 50 claps · 4.6 min read paywalled
#golang #gorm #sqlc #sql #orm
Open on Medium ↗

Why I Stopped Using ORMs in Go (And You Should Too)

golang sqlc x GORM

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:

  1. Install sqlc:

bash

go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest
  1. Create sqlc.yaml:

yaml

version: "2"
sql:
  - schema: "schema.sql"
    queries: "queries/"
    engine: "postgresql"
    gen:
      go:
        emit_interface: true
        out: "db"
        package: "db"
  1. Start with new features. Don’t rewrite everything at once.
  2. Gradually replace hot paths where performance matters.
  3. 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