Data Access in Go: database/sql as the Foundation
Go keeps database access explicit.
The standard library database/sql package defines how applications talk to SQL databases, and separate drivers (Postgres, MySQL, SQLite, and others) implement the actual protocol.
ORMs like GORM and helpers like sqlx sit on top of that same foundation - they do not replace the pool, transactions, or context-aware APIs.
Databases Basics collects runnable snippets; sibling articles cover pooling, transactions, migrations, caching, and team conventions.
Summary
database/sqlis a small, driver-agnostic API for connection pooling, queries, transactions, and prepared statements. Your code imports a driver with a blank import and callssql.Open.- Insight: Staying close to
database/sqlkeeps you portable across drivers, testable with fakes, and aligned with how Go HTTP and gRPC stacks propagatecontext.Contextinto storage calls. - Key Concepts: driver, DB pool, Rows, Scan, Tx, Stmt, QueryContext, repository pattern.
- When to Use: Default for services that speak SQL. Reach for an ORM when migrations, associations, and codegen dominate; reach for raw SQL when queries are hand-tuned and team SQL skill is high.
- Limitations/Trade-offs: No query builder in stdlib;
sql.Null*types are verbose; driver behavior differs for cancellation and prepared statement caching; N+1 risks move to ORM layers if you are not careful. - Related Topics: context deadlines, connection pool tuning, golang-migrate, embed for migrations, Redis cache-aside, observability for slow queries.
Foundations
Picture your service as layers: HTTP handler, domain service, storage repository, then *sql.DB.
The handler parses the request and passes r.Context() down.
The repository owns SQL strings (or generated SQL) and maps rows to domain types.
*sql.DB is a connection pool, not a single socket.
sql.Open("postgres", dsn) registers the driver name and returns a pool handle.
The first real connection often appears on Ping, Query, or Exec.
Drivers live in separate modules:
import (
"database/sql"
_ "github.com/jackc/pgx/v5/stdlib" // Postgres
)The blank import runs the driver's init() so sql.Open can resolve "pgx".
Swapping databases means swapping the driver import and DSN, not rewriting application types if repositories hide SQL details.
Mechanics & Interactions
The idiomatic read path:
func GetUser(ctx context.Context, db *sql.DB, id int64) (User, error) {
var u User
err := db.QueryRowContext(ctx,
`SELECT id, email FROM users WHERE id = $1`, id,
).Scan(&u.ID, &u.Email)
if errors.Is(err, sql.ErrNoRows) {
return User{}, ErrNotFound
}
return u, err
}QueryRowContext expects at most one row.
Scan copies column values into Go variables and reports type mismatches immediately.
Multi-row reads use QueryContext, iterate rows.Next(), and always rows.Close().
Writes use ExecContext; batch jobs may use transactions via BeginTx.
| Layer | Responsibility | Typical package |
|---|---|---|
| Handler | Auth, binding, status codes | chi, gin, echo |
| Service | Business rules, orchestration | internal/order |
| Repository | SQL, mapping, transactions | internal/storage |
| Pool | Connections, limits, health | database/sql |
| Driver | Wire protocol, cancel | pgx, go-sql-driver/mysql |
Frameworks (chi, gin, echo) do not change the storage contract: repositories still accept ctx and *sql.DB (or an interface wrapping them).
google.golang.org/grpc services pass the RPC context into repository methods the same way HTTP handlers do.
Advanced Considerations & Applications
Production services combine pool settings with per-query context:
SetMaxOpenConnscaps simultaneous connections to the database.SetMaxIdleConnskeeps warm connections for bursty traffic.SetConnMaxLifetimerotates connections behind load balancers.QueryContextties work to client disconnect and service deadlines.
ORMs (GORM) generate SQL and manage associations; sqlx adds struct scanning helpers while staying close to raw SQL.
Teams often choose:
| Approach | Strength | Weakness | Best Fit |
|---|---|---|---|
Raw database/sql | Full control, zero magic | More boilerplate | Performance-critical SQL |
| sqlx | Ergonomic scanning | Still manual schema | Services with moderate SQL |
| GORM | Migrations, hooks, associations | Hidden queries, magic tags | CRUD-heavy admin APIs |
| sqlc / codegen | Compile-time query checks | Build pipeline required | Large teams with stable schemas |
Observability belongs at the repository boundary: log query name, latency, and ctx deadline remaining; expose pool stats (db.Stats()) to metrics.
controller-runtime reconcilers should pass reconcile context into repository calls so database work stops when the manager shuts down.
Common Misconceptions
- "
sql.Openconnects immediately" - It allocates a pool; errors often appear on first use. AlwaysPingContextat startup. - "ORMs replace database/sql" - They wrap it. You still need pool tuning and driver upgrades.
- "One global
*sql.DBis an anti-pattern" - A single pool per process is normal; the anti-pattern is hiddeninit()wiring without tests. - "Context is optional for fast queries" - Client cancel should stop work even for millisecond queries under load.
- "Repositories must return
sql.Rows" - Return domain types or slices; keepRowsinside the repository. - "Prepared statements are always faster" - Depends on driver caching and query shape; measure before blanket
Prepare.
FAQs
Why blank-import the driver?
Drivers register themselves in init().
Without the import, sql.Open fails with "unknown driver".
Should handlers call db.Query directly?
Keep SQL in repositories so handlers stay thin and tests can fake storage.
How do I handle sql.ErrNoRows?
Map to a domain ErrNotFound sentinel.
Do not leak sql.ErrNoRows to HTTP layers without translation.
One pool per request?
No - one *sql.DB per process (or per logical database) is standard.
Requests borrow connections from the pool.
Does GORM hide context?
Modern GORM supports WithContext(ctx).
Still verify generated SQL and pool settings in production.
How do tests avoid a real database?
Use interfaces at the service boundary, sqlmock, or ephemeral containers for integration tests.
Unit tests should not need Docker for business rules.
What about NoSQL?
This section focuses on SQL via database/sql.
Document stores use different client SDKs with their own pooling patterns.
Can I share a pool across microservices?
Each service process owns its pool.
Sharing one pool across services couples failure domains.
How does pgx differ from database/sql?
pgx offers a native API and a database/sql driver via stdlib.
Most apps use the stdlib compatibility layer.
When is an ORM worth the cost?
When schema churn, associations, and soft-delete hooks dominate developer time more than query tuning.
Re-evaluate when performance becomes the bottleneck.
Related
- Databases Basics - runnable Open, Query, Scan examples
- database/sql Connection Pooling & Timeouts - pool and deadline tuning
- Transactions, Prepared Statements & sql.Tx - Tx boundaries
- GORM vs sqlx vs Raw database/sql - stack comparison
- context in database/sql & gRPC - cancellation integration
- Databases & Data Access Best Practices - team conventions
Stack versions: This page was written for Go 1.26.x (Green Tea GC default, go fix modernizers - verify patch at build), chi (latest - verify at build), gin (latest - verify at build), echo (latest - verify at build), google.golang.org/grpc (latest - verify at build), sigs.k8s.io/controller-runtime (latest - verify at build), kubebuilder (latest - verify at build), tinygo (latest - verify board targets at build), wazero (latest - verify at build), and golangci-lint (latest - verify linter set at build).