So you've started a new Go service.
It's going to talk to Postgres. Or MySQL. Or whatever your team picked at the last architecture meeting. Either way, you need to make a choice that will shape the next three years of this codebase: how is your Go code going to talk to SQL?
You open the docs. There's database/sql in the standard library. There's sqlx, which a lot of people swear by. There's sqlc, which generates code from SQL. There's GORM, which feels like Rails. And there's a quiet voice in the back of your head saying "just pick one and go," but you also remember the last project where the wrong choice meant rewriting half the data layer two years in.
Here's the thing nobody tells you up front: in Go, choosing a database library is choosing how much control you want to give up. That's the whole game. The standard library gives you everything and asks for a lot of typing. GORM gives you a lot of typing back, but hides what's running. sqlx and sqlc sit in between, but they sit in different places along the line.
Let's go through them like we're actually picking one for a real service.
The starting point: database/sql
Every Go database library is built on top of database/sql. So it's worth knowing what you get for free before you reach for anything else.
database/sql is a thin interface. You bring a driver (github.com/lib/pq, github.com/jackc/pgx/v5/stdlib, github.com/go-sql-driver/mysql), open a connection pool, and then you talk to it with Query, QueryRow, and Exec. There's no magic. You write SQL strings, you bind parameters, and you scan rows into variables.
package main
import (
"context"
"database/sql"
"fmt"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
)
type User struct {
ID int64
Email string
CreatedAt time.Time
}
func GetUserByID(ctx context.Context, db *sql.DB, id int64) (*User, error) {
row := db.QueryRowContext(ctx,
`SELECT id, email, created_at FROM users WHERE id = $1`, id)
var u User
if err := row.Scan(&u.ID, &u.Email, &u.CreatedAt); err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("user %d not found", id)
}
return nil, fmt.Errorf("scan user: %w", err)
}
return &u, nil
}
Read that and notice what's not there. No struct tags. No reflection. No query builder. No "model" type registered somewhere. You wrote the SQL, you scanned the fields in order, and you got back a *User. The compiler knows what you wrote. The runtime knows what you wrote. And in three months when you're staring at a slow query in production, the query in your code is the query Postgres is running. No middle layer.
That last point is the real superpower. With database/sql, the SQL you read in the file is the SQL the database executes. Nothing rewrites it. Nothing decides to add a LEFT JOIN for you. Nothing silently fetches 200 related rows because you accessed a field. It's boring, in the best way.
But you also feel the cost immediately. Look at this:
rows, err := db.QueryContext(ctx,
`SELECT id, email, created_at FROM users WHERE tenant_id = $1`, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var users []User
for rows.Next() {
var u User
if err := rows.Scan(&u.ID, &u.Email, &u.CreatedAt); err != nil {
return nil, err
}
users = append(users, u)
}
if err := rows.Err(); err != nil {
return nil, err
}
return users, nil
That's a lot of code for "get me the users in this tenant." And you write that ceremony every single time. Forget defer rows.Close() once and you've leaked a connection. Forget rows.Err() and you might silently truncate results. Forget the order of fields in Scan and the compiler won't help you. The columns are positional, not named.
So database/sql is the most honest tool in the Go ecosystem. It hides nothing. The price is that you write a lot of glue code, and every piece of that glue code is somewhere you can introduce a bug.
This is the baseline. Every other tool we're about to look at is answering a single question: "which parts of that ceremony can we make disappear without losing control of the SQL?" Each one answers it differently.

sqlx: the smallest possible upgrade
sqlx is the version of database/sql that someone wrote after getting tired of typing Scan(&u.ID, &u.Email, &u.CreatedAt) for the four-hundredth time.
It's a small library. It wraps the standard *sql.DB and adds three things you'll use constantly: scanning into structs by column name, named parameter binding, and In() for slice arguments. That's it. It doesn't try to be an ORM. It doesn't generate anything. It doesn't introduce a separate concept of a "model."
The same query as before, with sqlx:
package users
import (
"context"
"time"
"github.com/jmoiron/sqlx"
)
type User struct {
ID int64 `db:"id"`
Email string `db:"email"`
CreatedAt time.Time `db:"created_at"`
}
func GetUserByID(ctx context.Context, db *sqlx.DB, id int64) (*User, error) {
var u User
err := db.GetContext(ctx, &u,
`SELECT id, email, created_at FROM users WHERE id = $1`, id)
if err != nil {
return nil, err
}
return &u, nil
}
func ListUsersByTenant(ctx context.Context, db *sqlx.DB, tenantID int64) ([]User, error) {
var users []User
err := db.SelectContext(ctx, &users,
`SELECT id, email, created_at FROM users WHERE tenant_id = $1`, tenantID)
return users, err
}
That's the whole pattern. Get for one row, Select for many rows, struct tags map columns to fields. The rows-loop ceremony is gone. The connection-leak risk is gone. And, this is the part that matters, the SQL is still right there in the file. You didn't trade clarity for convenience. You traded only the glue.
sqlx also has named parameters, which read better than $1, $2, $3 once a query has more than two arguments:
_, err := db.NamedExecContext(ctx, `
INSERT INTO users (email, tenant_id, role)
VALUES (:email, :tenant_id, :role)
`, map[string]interface{}{
"email": "ada@example.com",
"tenant_id": 42,
"role": "admin",
})
And sqlx.In rewrites a slice into the right number of placeholders, which sounds small until you've spent an afternoon trying to build a dynamic WHERE id IN (...) clause by hand:
query, args, err := sqlx.In(
`SELECT id, email FROM users WHERE id IN (?)`,
[]int64{1, 2, 3, 4})
if err != nil {
return nil, err
}
query = db.Rebind(query) // adjust ? → $1, $2... for Postgres
var users []User
err = db.SelectContext(ctx, &users, query, args...)
What's not in sqlx? Anything that looks like a query builder. Anything that generates SQL for you. Anything that thinks for you. The library is, on purpose, a thin layer that removes the boilerplate without taking your hands off the wheel.
The trade is that you don't get type safety on your queries. The SQL is a string. If you misspell a column, or change email to email_address in a migration and forget to update the query, the compiler is fine with it and the bug shows up at runtime. The struct tag tells sqlx how to scan, but it doesn't tell sqlx what's in the query. Those two facts can drift apart, and they often do.
For most services I've seen, that's a fine trade. The SQL is small, the schema doesn't change weekly, and runtime errors during testing catch the drift. But if your codebase has hundreds of queries and a schema that evolves fast, you start wishing the compiler had your back.
That's where sqlc comes in.
sqlc: SQL is the source, Go is the output
sqlc inverts the relationship. Instead of writing Go code that contains SQL strings, you write .sql files and sqlc generates Go code that calls them.
The flow looks like this. You have a query.sql file:
-- name: GetUserByID :one
SELECT id, email, created_at
FROM users
WHERE id = $1;
-- name: ListUsersByTenant :many
SELECT id, email, created_at
FROM users
WHERE tenant_id = $1
ORDER BY created_at DESC;
-- name: CreateUser :one
INSERT INTO users (email, tenant_id, role)
VALUES ($1, $2, $3)
RETURNING id, email, created_at;
And a schema.sql that defines the tables. sqlc reads both, type-checks the queries against the schema, and emits a Go file you import like any other package:
package users
import (
"context"
"yourapp/db/queries" // generated by sqlc
)
func GetUserByID(ctx context.Context, q *queries.Queries, id int64) (queries.User, error) {
return q.GetUserByID(ctx, id)
}
func ListUsersByTenant(ctx context.Context, q *queries.Queries, tenantID int64) ([]queries.User, error) {
return q.ListUsersByTenant(ctx, tenantID)
}
The generated queries.User is a struct with fields of the exact types sqlc inferred from your schema. The generated q.GetUserByID takes a typed argument and returns a typed row. If you rename a column in schema.sql and forget to update query.sql, sqlc fails at code-gen time, before your code even compiles. If you change a column from int to bigint, the generated struct field changes, and every caller that was passing an int32 now gets a compile error.
That's a big deal. It moves a whole category of database bugs from "production at 3am" to "you haven't even pushed yet."
The other thing sqlc gets right is that it doesn't take your SQL away from you. The query in the file is the query the database runs. There's no clever rewriting. There's no abstraction layer translating between an ORM dialect and your database. The Go file is generated; the SQL file is yours. When the query is slow, you read the SQL. When the SQL needs an index, you add the index. The mental model is identical to database/sql, just with the boilerplate replaced by code-gen.
It's not perfect, though, and the rough edges are worth knowing about.
Dynamic queries are awkward. If your query needs to optionally include a WHERE clause depending on a filter, sqlc has limited support and you often end up either writing the dynamic part in raw database/sql or using sqlc's sqlc.narg and conditional patterns. For a search endpoint with seven optional filters, the file gets ugly. Some teams give up on sqlc for those specific queries and reach for a query builder or write the dynamic ones by hand.
You need a schema file. sqlc has to know your schema to type-check queries. The recommended pattern is a schema.sql that mirrors your migrations. Some teams pipe their migration files in directly; some duplicate them. Either way, sqlc and your migrations need to stay in sync, which is one more thing to keep an eye on. (sqlc can also point at a live database with database mode in newer versions, which avoids the duplication.)
Generated code can feel heavy. Every query becomes a method on the Queries struct, with its own row type and argument type. For a service with two hundred queries, that's a lot of generated symbols. It's worth it for the safety, but the diff during reviews is large, and grepping for "where is this query defined" lands you in a generated file.
For a service where the data layer is the heart of the app (billing, identity, anything regulated), sqlc is, in my experience, the best of these options. The compiler is on your side, the SQL is honest, and the team isn't reinventing scan logic.
For a small service or a prototype, it might be more ceremony than you need. And that's a fine place for the last option.
GORM: the ORM that knows what you mean
GORM is the one I've seen the most heat about, in both directions. Some teams love it. Some teams swear they'll never touch it again. Both are right, about different services.
GORM is a full ORM. You define a struct with tags, GORM treats it as a model, and you do most of your data work by calling methods on the model:
package users
import (
"context"
"time"
"gorm.io/gorm"
)
type User struct {
ID int64 `gorm:"primaryKey"`
Email string `gorm:"uniqueIndex;not null"`
TenantID int64 `gorm:"index;not null"`
Role string `gorm:"size:32;not null"`
CreatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"` // soft delete
}
func GetUserByID(ctx context.Context, db *gorm.DB, id int64) (*User, error) {
var u User
if err := db.WithContext(ctx).First(&u, id).Error; err != nil {
return nil, err
}
return &u, nil
}
func ListUsersByTenant(ctx context.Context, db *gorm.DB, tenantID int64) ([]User, error) {
var users []User
err := db.WithContext(ctx).
Where("tenant_id = ?", tenantID).
Order("created_at desc").
Find(&users).Error
return users, err
}
Notice you didn't write any SQL. You described what you wanted ("first user with this id," "all users in this tenant, sorted by creation time") and GORM built the SQL for you. It also handles soft deletes: the DeletedAt field automatically makes every query exclude deleted rows. It supports preloading associations, hooks like BeforeCreate, transactions, migrations, and a query builder for the more complex cases.
For an MVP, this is great. You can stand up a working data layer in an afternoon. The model file doubles as documentation. The migrations write themselves if you let GORM do automigrate (though you probably shouldn't in production, more on that in a second).
The trouble starts when the service grows.
The first thing you notice is that the SQL GORM generates is not the SQL you'd write by hand. It's correct, usually, but it's verbose. It selects every column even when you only want two. It adds the soft-delete filter to every query, which is what you want most of the time and exactly what you don't want during a one-off data fix. It does query batching in ways that can surprise you. If you're not watching the EXPLAIN output, the SQL drifts away from what's optimal.
The second thing you notice is the N+1 problem. If you have Users with Posts and you do:
var users []User
db.Find(&users)
for _, u := range users {
var posts []Post
db.Where("user_id = ?", u.ID).Find(&posts)
// ...
}
You just sent one query per user. With 500 users, that's 501 round trips. GORM has Preload and joined loading to fix this, but you have to remember to use them, and you have to notice you have the problem. With raw SQL or sqlc, that loop wouldn't have written itself; you'd have written one LEFT JOIN and been done. With GORM, the path of least resistance leads you into the trap.
The third thing you notice is debugging. When a GORM query is slow, you can't just read the file and see the SQL. You have to enable GORM's logger, capture the actual rendered statement, then read that, then figure out which GORM call produced it. It's not impossible. It's just more friction at exactly the moment you don't want friction.
GORM is at its best when the data shape is simple, the schema is small, and the team values speed of writing over speed of reading. It's at its worst when the queries are non-trivial, the data layer is the bottleneck, or you need to optimise a slow endpoint without rewriting the model layer first.
A lot of GORM frustration is actually frustration with using GORM in places it wasn't a good fit. It's not a bad library. It's just a high-control-cost library. You're trading a lot of "I can see what SQL is running" for "I don't have to write that SQL."
The actual trade-offs, in one place
If you read the four sections above and felt like each library was being recommended over the others, you've understood the article. The right answer depends on what your service is and how it'll grow.
Here's the way I think about it.
Use database/sql directly when: the data layer is small, the team is comfortable with SQL, the queries are stable, and you care more about minimising dependencies than about saving keystrokes. Some of the most reliable Go services I've seen use nothing else. It's also the right call for libraries. If you're writing a Go package other people will import, dragging in sqlx or GORM is usually a courtesy you don't want to impose.
Use sqlx when: you want the standard library's honesty but you're tired of the scan ceremony. It's the safest upgrade: minimal new concepts, easy to walk back if you change your mind, and the SQL is still right there in the file. For services with maybe 20 to 50 queries and a stable-ish schema, sqlx is often the highest-value-per-line-of-config choice.
Use sqlc when: the data layer is the core of the service, the schema changes often enough that runtime errors scare you, or your team is big enough that you want the compiler to catch query-vs-schema drift on every PR. The setup cost is real (you have to wire up the generator, manage the schema file, learn the directives), but on a service with hundreds of queries the type safety pays for itself many times over.
Use GORM when: you're moving fast, the schema is simple, and the team's instinct is "I'd rather describe my data than write SQL for every CRUD operation." It's the right call for admin backends, internal tools, and the first version of a service whose data layer isn't expected to be a bottleneck. Be ready to migrate parts of it to a thinner tool when the service grows up.
And here's the thing nobody puts in these comparison posts: you can use more than one. There's no rule that says every query in your service has to go through the same library. I've seen real codebases where the bulk of the data layer is sqlc (typed, generated, audited) but the search endpoint with seven optional filters uses raw database/sql because nothing handled dynamic queries better. I've seen GORM in a service's admin section and database/sql everywhere else. As long as the team is honest about which tool they're reaching for and why, mixing them is fine.
The thing you really want to avoid is not deciding. The codebase that drifts toward "well, this part uses GORM because Alex wrote it, this part uses sqlx because Sam preferred it, and this part is raw database/sql because we forgot", that's the painful one. Not because any of the libraries are bad, but because the team has now committed to maintaining three sets of patterns for the same problem and nobody knows which one is canonical.
Pick one as the default. Document it. Make the other two an explicit, deliberate choice with a one-line comment explaining why. That's the part of the answer that has nothing to do with Go.
What stays the same no matter which tool you pick
A few things are true across all four tools, and they matter more than the tool choice itself.
Always pass a context.Context. Every query, every transaction. Without a context, a slow query in production has no way to be cancelled when the caller times out, and you'll get goroutines piling up against the connection pool. All four libraries support *Context variants of every method. Use them as the default, not the exception.
Tune the connection pool. *sql.DB is a pool, not a connection. It has knobs: SetMaxOpenConns, SetMaxIdleConns, SetConnMaxLifetime, SetConnMaxIdleTime. The defaults are not what you want. If you don't set these, you'll eventually hit a load spike that opens a thousand connections to your database and brings the whole thing down. Set them once, write them down somewhere, and revisit them when the service grows.
Watch your queries in production. Whatever tool you picked, log the slow ones. sqlx and database/sql make this easy (you have the query string right there). sqlc generates the query into a constant you can log. GORM has a logger you can configure to surface slow queries. The point is, somebody on the team should be able to answer "what was the slowest query last week?" within five minutes. If nobody can, the choice of database library is the least of your problems.
Use migrations, not auto-migrate. golang-migrate, goose, or atlas, any of them. The schema is too important to be a side-effect of struct tags. This is the one rule that's the same regardless of whether you're on database/sql or GORM: schema changes go through a versioned migration tool, reviewed like any other code change.
If you do those four things, the choice between database/sql, sqlx, sqlc, and GORM is mostly about taste and team size. Get them wrong, and no library will save you.
A small confession about the spectrum
Earlier in the article I drew a neat line: "control on the left, convenience on the right." That's a useful diagram for getting started, but it's not quite true. sqlc isn't just sqlx-with-codegen. GORM isn't just sqlc-with-more-magic. They're different bets about where the abstraction should live.
database/sql and sqlx put the abstraction in the standard library and your own code. sqlc puts it in a code generator that runs at build time. GORM puts it in a runtime library that sits between your code and the driver.
Each location has consequences. Build-time abstractions (sqlc) catch bugs early but slow down dynamic queries. Runtime abstractions (GORM) flex with the code but obscure what runs. Your-own-code abstractions (sqlx, raw database/sql) put the responsibility on you, which is great if you trust the team and dangerous if you don't.
That's the real trade. Once you see it that way, the question stops being "which library is best" and becomes "where do I want the SQL to live in our process?" And that's a question only you and your team can answer.
You're not picking a library. You're picking how your service breathes.






