40.6 Choosing Between Raw SQL, sqlc, GORM, and ent
Right, let’s settle this. You’re building something real, and you need to talk to a database. This isn’t academic; it’s a choice that will define your application’s velocity, stability, and your personal sanity for months to come. We’re going to break down the four main ways you can do this in Go, from the raw metal of SQL to the full abstraction of an ORM. I’m not here to sell you on one. I’m here to make sure you know what you’re buying.
The Lay of the Land: A Spectrum of Control
Think of it as a spectrum. On one end, you have raw SQL in your Go code: maximum control, maximum responsibility. On the other end, you have full ORMs like GORM and ent: maximum abstraction, sometimes at the cost of clarity. Sitting in the pragmatic middle, we have sqlc, which is like having a brilliant intern who writes all your boring boilerplate code for you. Your job is to figure out where on this spectrum your project belongs.
Raw SQL: The Power Tool
You write the SQL. You handle the scanning. You manage the struct tags. It’s just you, database/sql, and a whole lot of rows.Scan(&thing, &otherThing).
// You're in charge. No magic, just work.
query := `
SELECT id, name, email, created_at
FROM users
WHERE country_code = $1 AND archived = false
`
rows, err := db.Query(query, "US")
if err != nil {
log.Fatal(err)
}
defer rows.Close()
var users []User
for rows.Next() {
var u User
err := rows.Scan(&u.ID, &u.Name, &u.Email, &u.CreatedAt)
if err != nil {
log.Fatal(err)
}
users = append(users, u)
}
if err = rows.Err(); err != nil {
log.Fatal(err)
}
Why you’d use it: Unbeatable control and performance. You write exactly the query you need, and you understand every nanosecond it takes. It’s perfect for complex reports, analytical queries, or situations where any abstraction just gets in the way.
The catch: It’s verbose, and it’s brittle. Change a column name in your database? Hope you remembered to grep for it in every one of your .go files. Also, welcome to the wonderful world of SQL injection if you get lazy with string concatenation. Don’t. Get. Lazy.
sqlc: The Pragmatist’s Paradise
This is the one I recommend most often because it solves the biggest pain point of raw SQL: the boilerplate. You write SQL in .sql files. sqlc reads your database schema, analyzes your queries, and generates type-safe Go code for you, including the method signatures and the structs to scan into. It’s pure sorcery.
- You write a
queries/users.sqlfile:-- name: GetUsersByCountry :many SELECT id, name, email, created_at FROM users WHERE country_code = $1 AND archived = false; - You run
sqlc generate. - sqlc gives you a Go method to call:
// Go code generated by sqlc - you never write this! users, err := queries.GetUsersByCountry(ctx, "US") if err != nil { log.Fatal(err) } // 'users' is already a nicely typed slice of []User structs!
Why you’d use it: It gives you the power and predictability of hand-written SQL while completely eliminating the tedious and error-prone manual mapping. The generated code is impeccable. It’s a massive productivity boost and a huge win for type safety.
The catch: You still need to know SQL. This isn’t an abstraction; it’s a code generator. It also can’t handle every single edge case of dynamic SQL (though its support is impressive), so you might occasionally need to drop down to raw database/sql for a truly weird query.
GORM: The “Just Make It Work” ORM
GORM is the popular, full-featured ORM. You primarily work with Go structs, and GORM figures out the SQL for you. It’s a beast of features: associations, hooks, migrations, you name it.
// Define your model
type User struct {
gorm.Model
Name string
Email string `gorm:"uniqueIndex"`
CountryCode string
}
// Querying feels like magic... sometimes black magic.
var users []User
result := db.Where("country_code = ?", "US").Not("archived = ?", true).Find(&users)
if result.Error != nil {
log.Fatal(result.Error)
}
Why you’d use it: Rapid prototyping. If you need to spin up a CRUD API in an afternoon, GORM is your friend. It can be incredibly expressive for simple operations.
The catch: The magic is its greatest weakness. It’s easy to write a simple line of Go that generates shockingly inefficient SQL (N+1 query problem, anyone?). Debugging means you’re now debugging both your Go code and the SQL it generates, which feels like debugging through a translator. Its flexibility can also lead to inconsistent patterns in a large codebase.
ent: The Type-Safe, Code-First Contender
Where GORM is loose and magical, ent is strict and explicit. It’s a schema-first, code-based framework where you define your schema in Go. ent then generates a ridiculously type-safe and powerful API for you.
- You define your schema in Go:
// <ent/schema/user.go> func (User) Fields() []ent.Field { return []ent.Field{ field.String("name"), field.String("email").Unique(), field.String("country_code"), } } - You run
go generate ./.... - You get a generated API that feels like it’s from the future:
// Query with a compiler-checked, fluent API. No magic strings. users, err := client.User. Query(). Where(user.CountryCode("US"), user.Archived(false)). All(ctx)
Why you’d use it: Unparalleled type safety and performance. The generated code is extremely efficient. Its fluent API makes complex queries a joy to write, and it’s virtually impossible to make a typo in a field name. It’s designed for large, complex applications.
The catch: It’s a heavier lift to learn and set up. It has its own way of doing everything. While its API is brilliant, it’s also very specific, and you’re buying into the ent ecosystem. It’s less “I’ll just write a quick SQL query” and more “I will design the perfect query within the framework.”
So, What Do You Choose?
- Choose Raw SQL if you’re a control freak working on a system where every microsecond counts, or your queries are too complex for any tool to handle.
- Choose sqlc for 95% of applications. You know SQL, you want type safety, and you hate boilerplate. It’s the best trade-off.
- Choose GORM if you’re prototyping fast, your data model is simple, and you value feature breadth over predictable performance.
- Choose ent if you’re building a large, complex application and you want to leverage strong typing all the way down to your database calls, and you’re willing to invest in its philosophy.
My personal stack? sqlc for all the standard data access, and a tiny raw.go package for the two queries per project that are too bonkers for any tool to handle. It gives me the safety net without sacrificing the power. Now go build something.