Skip to content
ExponaBLOG
GO PROGRAMMING & PRODUCTION APIS · DAY 028 / 100

GORM: when to use an ORM — practical tips and trade-offs

Productivity vs control.

Expona Admin6 min read

Introduction

By day 28 of building production APIs in Go, you've likely reached the point where raw SQL queries feel repetitive and error-prone, yet you're unsure whether an ORM like GORM will solve more problems than it creates. The truth is that GORM: when to use an ORM — practical tips and trade-offs isn't a binary choice; it's about understanding where productivity gains outweigh the loss of control, and where dropping back to raw SQL remains the smarter move.

We've shipped systems using GORM across e-commerce platforms, CRM backends, and high-traffic SaaS applications. This guide distills what we've learned about when to embrace GORM and when to avoid its overhead.

GORM shines for CRUD-heavy applications

If your API spends most of its time performing straightforward Create, Read, Update, and Delete operations against your database, GORM pays dividends immediately. Instead of writing boilerplate SQL and manually scanning rows into structs, you write declarative Go code that reads like business logic rather than database plumbing.

Consider a user service in an e-commerce platform. Without GORM, creating a user involves:

go
// Raw SQL approach
const createUserSQL = `
  INSERT INTO users (email, name, created_at)
  VALUES ($1, $2, NOW())
  RETURNING id, email, name, created_at
`

var user User
err := db.QueryRowContext(ctx, createUserSQL, email, name).Scan(
  &user.ID, &user.Email, &user.Name, &user.CreatedAt,
)
if err != nil {
  return nil, err
}

With GORM, it becomes:

go
// GORM approach
user := &User{Email: email, Name: name}
if err := db.Create(user).Error; err != nil {
  return nil, err
}
// user.ID is now populated

The difference compounds when you have twenty entities and dozens of endpoints. GORM also handles migrations, relationships, hooks, and soft deletes—features that would otherwise require hand-rolled infrastructure code. For teams shipping features rapidly, this velocity matters.

However, productivity gains assume your queries remain simple. The moment you need complex joins, aggregations, or window functions, GORM's abstraction can become a liability rather than an asset.

Preload to avoid N+1 query disasters

One of the most common pitfalls in GORM applications is the N+1 query problem. You fetch a list of orders, and then inside a loop you query the database for each order's customer details. What should be two queries becomes one plus N additional queries, tanking performance under load.

GORM provides Preload to fetch related data in a single or minimal number of queries:

go
// N+1 disaster
var orders []Order
db.Find(&orders)
for _, order := range orders {
  var customer Customer
  db.First(&customer, order.CustomerID)
  // Now you have 1 + len(orders) queries
}

The fix is explicit and straightforward:

go
// Preload solution
var orders []Order
db.Preload("Customer").Find(&orders)
// Only 2 queries: one for orders, one for all customers

// For nested relationships
db.Preload("Customer").Preload("Items").Find(&orders)

You can also use Preload with conditions:

go
db.Preload("Items", "status = ?", "completed").Find(&orders)

The key insight is that Preload trades explicitness for efficiency. Once you're in the habit of checking your SQL output—more on that below—N+1 issues become rare and caught during development rather than in production dashboards.

Watch the generated SQL like a hawk

GORM abstracts away SQL, but abstraction is only safe if you verify what's actually happening at the database level. We've seen teams assume GORM would generate efficient queries, only to discover they were executing cartesian joins across millions of rows.

Enable SQL logging in your development environment:

go
import "gorm.io/logger"

db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
  Logger: logger.Default.LogMode(logger.Info),
})
// Now GORM prints every query to stdout

Or use a more structured approach:

go
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
  Logger: logger.New(
    log.New(os.Stdout, "\r\n", log.LstdFlags),
    logger.Config{
      SlowThreshold: 200 * time.Millisecond,
      LogLevel:      logger.Info,
    },
  ),
})

When you call db.Where("age > ?", 25).Find(&users), look at the actual SQL. Is it filtering before the join, or after? Is it using an index? Are there implicit type conversions that prevent index usage?

A query that looks elegant in GORM syntax can generate suboptimal SQL. A simple rule: if you wouldn't trust the generated SQL without reviewing it, enable logging and review it. This habit catches performance regressions early and builds confidence in your ORM usage.

Drop to raw SQL for complex queries

The moment you need window functions, CTEs, complex aggregations, or multi-table updates, GORM's query builder becomes a liability. Trying to express these operations through GORM's method chain results in unreadable code that's harder to debug than raw SQL.

GORM recognizes this and provides clean escape hatches. Use db.Raw() or db.Exec() for queries that don't map cleanly to ORM abstractions:

go
// Calculate monthly revenue per customer using a CTE
type RevenueRow struct {
  CustomerID int64
  Month      string
  Revenue    decimal.Decimal
}

var results []RevenueRow
db.Raw(`
  WITH monthly_revenue AS (
    SELECT
      customer_id,
      DATE_TRUNC('month', created_at) as month,
      SUM(total) as revenue
    FROM orders
    WHERE created_at >= NOW() - INTERVAL '12 months'
    GROUP BY customer_id, DATE_TRUNC('month', created_at)
  )
  SELECT customer_id, month::text, revenue FROM monthly_revenue
  ORDER BY revenue DESC
`).Scan(&results)

This approach keeps your business logic in Go while letting SQL do what it does best. You get the readability of raw SQL (which your team can review, optimize, and test independently) without the boilerplate of manual row scanning.

Many teams adopt a hybrid strategy: GORM for entity CRUD and simple queries, raw SQL for reporting and analytics. This hybrid approach often yields the best productivity-to-control ratio.

Common mistakes

  • Forgetting to call `.Error` after queries. GORM chains errors; you must check `.Error` at the end of a query chain or defer to a hook. Missing this leads to silent failures that propagate downstream.
  • Using `db.Session(&gorm.Session{})` without understanding context isolation. Each session spawns a new database connection. For concurrent handlers, this can exhaust your connection pool. Reuse your base `*gorm.DB` instance across requests.
  • Assuming `Preload` is always the right answer. Preloading a relationship with millions of rows is worse than pagination plus separate queries. Know your data volumes.
  • Not setting up proper transaction handling. GORM's `db.Transaction()` helper is convenient, but wrapping your business logic in defer-based rollback is often clearer and more testable.
  • Ignoring connection pool settings. GORM doesn't tune PostgreSQL connection limits by default. Under load, you'll hit `too many connections` errors. Set `MaxOpenConns` and `MaxIdleConns` explicitly.

Key takeaways

  • GORM is a productivity win for CRUD-heavy APIs; measure the time saved against the abstraction cost for your specific workload.
  • Always enable SQL logging in development and review generated queries for N+1 problems, inefficient joins, and missing index usage.
  • Use `Preload` intentionally and know when pagination plus separate queries is better than bulk preloading.
  • Drop to raw SQL for complex analytical queries, aggregations, and CTEs; GORM's query builder isn't worth fighting.
  • Configure connection pooling, handle transactions explicitly, and test your ORM usage paths as seriously as you test raw SQL paths.
  • The trade-off isn't GORM versus raw SQL; it's choosing the right tool for each query and being willing to switch within the same codebase.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles