Every Go project that talks to PostgreSQL eventually faces the same question: GORM, sqlx, or pgx? The full ORM, the thin wrapper, or the raw driver. I've shipped production systems with all three. Here's what I learned the hard way.
What you're actually choosing between
These three libraries sit at very different abstraction levels:
- GORM is a full ORM. It generates SQL, handles migrations, manages associations, and abstracts away most of SQL's surface area.
-
sqlx is a thin layer on top of
database/sql. It adds struct scanning and named queries but you still write SQL yourself. -
pgx is a PostgreSQL-specific driver. No
database/sqlcompatibility layer — direct protocol access, binary encoding, and PostgreSQL-native features.
Choosing the wrong one for your use case is the kind of mistake you discover six months later when you're debugging why GORM generated three JOINs you didn't ask for.
GORM: great until it isn't
GORM gets you to a working CRUD API fast. Struct tags define your schema, AutoMigrate creates the table, and you're querying with method chains in 20 lines.
package main
import (
"log"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
type Order struct {
gorm.Model
CustomerID uint
Total float64
Status string
}
func main() {
dsn := "host=localhost user=app dbname=shop sslmode=disable"
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err != nil {
log.Fatal(err)
}
db.AutoMigrate(&Order{})
var pending []Order
result := db.Where("status = ? AND total > ?", "pending", 100.0).
Order("created_at DESC").
Limit(50).
Find(&pending)
if result.Error != nil {
log.Fatal(result.Error)
}
log.Printf("found %d orders", len(pending))
}
This is clean. The problem starts when queries get complex. GORM's eager loading with Preload works until you have a model with five associations — then you're fetching 300 rows when you needed 30, and the N+1 problem bites hard.
The other issue is transparency. When something breaks, you add db.Debug() to log queries and find GORM generated a SELECT * FROM orders LEFT JOIN customers ON ... where you expected a simple SELECT id, total FROM orders. The abstraction leaks exactly when you need to understand it.
GORM is the right choice when: you're building an internal tool, a prototype, or an app where query complexity stays low and the team's SQL knowledge is limited.
sqlx: the pragmatic middle ground
sqlx keeps you in control of your SQL while removing the repetitive struct scanning boilerplate. You write the query; sqlx maps the result set to your struct.
package main
import (
"context"
"log"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
)
type OrderSummary struct {
ID int `db:"id"`
CustomerID int `db:"customer_id"`
Total float64 `db:"total"`
Status string `db:"status"`
}
func getPendingOrders(db *sqlx.DB, minTotal float64) ([]OrderSummary, error) {
const query = `
SELECT id, customer_id, total, status
FROM orders
WHERE status = 'pending' AND total > $1
ORDER BY created_at DESC
LIMIT 50
`
var orders []OrderSummary
err := db.SelectContext(context.Background(), &orders, query, minTotal)
return orders, err
}
func main() {
db, err := sqlx.Connect("postgres", "host=localhost user=app dbname=shop sslmode=disable")
if err != nil {
log.Fatal(err)
}
defer db.Close()
orders, err := getPendingOrders(db, 100.0)
if err != nil {
log.Fatal(err)
}
log.Printf("found %d orders", len(orders))
}
The SQL is explicit. You read it, you review it, you optimize it. No surprises in query logs. Named queries with NamedExec make bulk inserts readable. db.SelectContext vs db.GetContext covers 90% of read patterns without friction.
Where sqlx falls short: it's built on database/sql, which means it doesn't expose PostgreSQL-specific features like LISTEN/NOTIFY, COPY FROM, or pgx's batch query execution. For a high-throughput ingestion pipeline, this matters.
sqlx is the right choice when: you know SQL, your team values explicit queries over magic, and you don't need PostgreSQL-specific protocol features.
pgx: full control, full responsibility
pgx is a PostgreSQL driver first. The v5 API dropped database/sql compatibility in favor of a cleaner interface. If you're doing anything advanced — bulk inserts with COPY, streaming large result sets, LISTEN/NOTIFY for real-time events — pgx is the only option that doesn't fight you.
The performance difference is measurable. pgx uses binary protocol encoding by default, which cuts serialization overhead compared to the text protocol used by lib/pq. For services doing 10k+ queries per second, this shows up in profiling.
The tradeoff: you give up the database/sql ecosystem. Libraries that expect a *sql.DB won't compose with pgx's native pool. You also write more boilerplate for scanning — pgx v5's pgx.CollectRows with pgx.RowToStructByName helps, but it's more verbose than sqlx.
When I rebuilt a high-throughput event ingestion service targeting 50k inserts/sec into a partitioned table, switching from sqlx to pgx with the COPY protocol cut insert latency by 40% and reduced active database connections by half. That's the kind of win that justifies pgx's steeper learning curve.
What the decision actually looks like in practice
After running all three in production, here's the honest matrix:
| Concern | GORM | sqlx | pgx |
|---|---|---|---|
| Time to first working query | minutes | hours | days |
| Complex query control | poor | excellent | excellent |
| PostgreSQL-specific features | no | no | yes |
| Debugging ease | hard | easy | easy |
| Bulk insert performance | slow | slow | fast |
| SQL knowledge required | low | medium | high |
One thing none of these solve well out of the box: connection pool observability. In production you want to know how many connections are idle, busy, or waiting. pgx's pgxpool exposes this via Stat(). For sqlx/GORM on database/sql, db.Stats() is less granular but still useful.
Security-wise, all three support parameterized queries, which is what matters most for SQL injection prevention. The risk with GORM is developers reaching for db.Raw("SELECT * FROM users WHERE id = " + id) when the query builder can't express what they need — I've caught this in code reviews more than once. sqlx and pgx make parameterized queries the natural path because you're writing the SQL yourself. For a complete checklist of database security hardening practices — connection encryption, least-privilege roles, audit logging for PostgreSQL — the security hardening checklists we publish at AYI NEDJIMI Consultants cover this in detail.
The takeaway
- Use GORM if your team is small, the schema is simple, and speed of development matters more than query control.
-
Use sqlx as the default for most production Go services. You get explicit SQL, good ergonomics, and the full
database/sqlecosystem. - Use pgx when you need PostgreSQL-specific capabilities (COPY, LISTEN/NOTIFY, batch queries) or when throughput benchmarks show the wire overhead is worth addressing.
The biggest mistake I see teams make is starting with GORM because it's approachable, hitting a wall at scale, then rewriting queries piecemeal with db.Raw. If your service will handle significant load, start with sqlx. The migration cost later is real.
I run AYI NEDJIMI Consultants, a cybersecurity consulting firm. We publish free security hardening checklists — PDF and Excel.
Top comments (0)