Go Database Patterns
Database access is where most Go services spend their complexity budget.
Get connection management, transactions, and query patterns right.
Detailed reference material, loaded on demand:
references/query-patterns.md — full query/scan/rows patterns, null
handling, N+1 avoidance, connection-leak examples.
references/tooling.md — repository pattern implementation, sqlc
annotated queries, migration tooling and rules.
Read a reference file only when the summary below is not enough.
1. Connection Management
Configure the pool explicitly — the default is unbounded connections:
func OpenDB(dsn string) (*sql.DB, error) {
db, err := sql.Open("postgres", dsn)
if err != nil {
return nil, fmt.Errorf("open db: %w", err)
}
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(10)
db.SetConnMaxLifetime(5 * time.Minute)
db.SetConnMaxIdleTime(1 * time.Minute)
if err := db.PingContext(context.Background()); err != nil {
return nil, fmt.Errorf("ping db: %w", err)
}
return db, nil
}
| Setting |
Guideline |
MaxOpenConns |
Match your DB's max connections / number of app instances |
MaxIdleConns |
40-50% of MaxOpenConns |
ConnMaxLifetime |
5-10 minutes (prevents stale connections behind load balancers) |
ConnMaxIdleTime |
1-2 minutes |
2. Query Rules
- Parameterized queries only — string concatenation into SQL is an
injection vulnerability, no exceptions.
- Always pass context — use the
*Context variants
(QueryContext, QueryRowContext, ExecContext) so queries respect
cancellation and timeouts.
defer rows.Close() immediately after the error check, and check
rows.Err() after the iteration loop.
- Handle
sql.ErrNoRows explicitly with errors.Is, mapping it to
a domain error like ErrUserNotFound.
var user User
err := db.QueryRowContext(ctx,
"SELECT id, name, email FROM users WHERE id = $1", id,
).Scan(&user.ID, &user.Name, &user.Email)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUserNotFound
}
if err != nil {
return nil, fmt.Errorf("get user %s: %w", id, err)
}
Multi-row iteration patterns: references/query-patterns.md.
3. Transactions
Use a helper that guarantees rollback on error:
func WithTx(ctx context.Context, db *sql.DB, fn func(tx *sql.Tx) error) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
if err := fn(tx); err != nil {
if rbErr := tx.Rollback(); rbErr != nil {
return fmt.Errorf("rollback failed: %v (original: %w)", rbErr, err)
}
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit tx: %w", err)
}
return nil
}
Set isolation explicitly for critical operations:
sql.TxOptions{Isolation: sql.LevelSerializable}.
4. Structure and Tooling
- Repository pattern: define the interface at the consumer side,
implement it with concrete database access, map driver errors to
domain errors at this boundary.
- sqlc: prefer it for raw-SQL projects — generates type-safe Go from
annotated SQL, catching query/schema mismatches at build time.
- Migrations: use a tool (
goose, golang-migrate, atlas), one
migration per change, forward-only in production, with down SQL, run
as a separate step — not at server startup.
Implementations and examples: references/tooling.md.
5. Common Pitfalls
- Null columns: use
sql.NullString/sql.NullInt64 or pointer
fields (*string, nil = SQL NULL). Scanning NULL into a plain string
errors at runtime.
- N+1 queries: a query inside a loop over query results. Replace
with a JOIN or a batch query (
WHERE id = ANY($1)).
- Connection leaks: any early
return between Query and
defer rows.Close() leaks a connection from the pool.
Worked examples of each pitfall: references/query-patterns.md.
Verification Checklist
- Connection pool configured with explicit limits (
MaxOpenConns, MaxIdleConns, lifetimes)
- All queries use parameterized placeholders, never string concatenation
- All
QueryContext results have defer rows.Close() immediately after error check
rows.Err() checked after row iteration loop
sql.ErrNoRows handled explicitly with errors.Is
- Transactions use a helper that guarantees rollback on error
- Context propagated to all database calls (
*Context variants)
- Nullable columns use
sql.NullString / sql.NullInt64 or pointer types
- No N+1 query patterns — use JOINs or batch queries
- Migrations are versioned, reversible, and run separately from app startup
1---2name: go-database3description: Database patterns for Go services: database/sql, connection management, transactions, migrations, query builders, and ORM usage (sqlc, GORM, ent). Use when: "database access", "SQL query", "connection pool", "transactions", "database migration", "sqlc", "GORM", "ent", "prepared statement", "repository pattern". Not for: in-memory structures (go-data-structures), SQL security (go-security-audit), query profiling (go-performance-review).4license: MIT5---6
7# Go Database Patterns
8
9Database access is where most Go services spend their complexity budget.
10Get connection management, transactions, and query patterns right.
11
12Detailed reference material, loaded on demand:
13
14- `references/query-patterns.md` — full query/scan/rows patterns, null
15 handling, N+1 avoidance, connection-leak examples.
16- `references/tooling.md` — repository pattern implementation, sqlc
17 annotated queries, migration tooling and rules.
18
19Read a reference file only when the summary below is not enough.
20
21## 1. Connection Management
22
23Configure the pool explicitly — the default is unbounded connections:
24
25```go
26func OpenDB(dsn string) (*sql.DB, error) {
27 db, err := sql.Open("postgres", dsn)
28 if err != nil {
29 return nil, fmt.Errorf("open db: %w", err)
30 }
31
32 db.SetMaxOpenConns(25)
33 db.SetMaxIdleConns(10)
34 db.SetConnMaxLifetime(5 * time.Minute)
35 db.SetConnMaxIdleTime(1 * time.Minute)
36
37 if err := db.PingContext(context.Background()); err != nil {
38 return nil, fmt.Errorf("ping db: %w", err)
39 }
40
41 return db, nil
42}
43```
44
45| Setting | Guideline |
46|---|---|
47| `MaxOpenConns` | Match your DB's max connections / number of app instances |
48| `MaxIdleConns` | 40-50% of MaxOpenConns |
49| `ConnMaxLifetime` | 5-10 minutes (prevents stale connections behind load balancers) |
50| `ConnMaxIdleTime` | 1-2 minutes |
51
52## 2. Query Rules
53
541. **Parameterized queries only** — string concatenation into SQL is an
55 injection vulnerability, no exceptions.
562. **Always pass context** — use the `*Context` variants
57 (`QueryContext`, `QueryRowContext`, `ExecContext`) so queries respect
58 cancellation and timeouts.
593. **`defer rows.Close()`** immediately after the error check, and check
60 `rows.Err()` after the iteration loop.
614. **Handle `sql.ErrNoRows` explicitly** with `errors.Is`, mapping it to
62 a domain error like `ErrUserNotFound`.
63
64```go
65var user User
66err := db.QueryRowContext(ctx,
67 "SELECT id, name, email FROM users WHERE id = $1", id,
68).Scan(&user.ID, &user.Name, &user.Email)
69
70if errors.Is(err, sql.ErrNoRows) {
71 return nil, ErrUserNotFound
72}
73if err != nil {
74 return nil, fmt.Errorf("get user %s: %w", id, err)
75}
76```
77
78Multi-row iteration patterns: `references/query-patterns.md`.
79
80## 3. Transactions
81
82Use a helper that guarantees rollback on error:
83
84```go
85func WithTx(ctx context.Context, db *sql.DB, fn func(tx *sql.Tx) error) error {
86 tx, err := db.BeginTx(ctx, nil)
87 if err != nil {
88 return fmt.Errorf("begin tx: %w", err)
89 }
90
91 if err := fn(tx); err != nil {
92 if rbErr := tx.Rollback(); rbErr != nil {
93 return fmt.Errorf("rollback failed: %v (original: %w)", rbErr, err)
94 }
95 return err
96 }
97
98 if err := tx.Commit(); err != nil {
99 return fmt.Errorf("commit tx: %w", err)
100 }
101 return nil
102}
103```
104
105Set isolation explicitly for critical operations:
106`sql.TxOptions{Isolation: sql.LevelSerializable}`.
107
108## 4. Structure and Tooling
109
110- **Repository pattern:** define the interface at the consumer side,
111 implement it with concrete database access, map driver errors to
112 domain errors at this boundary.
113- **sqlc:** prefer it for raw-SQL projects — generates type-safe Go from
114 annotated SQL, catching query/schema mismatches at build time.
115- **Migrations:** use a tool (`goose`, `golang-migrate`, `atlas`), one
116 migration per change, forward-only in production, with `down` SQL, run
117 as a separate step — not at server startup.
118
119Implementations and examples: `references/tooling.md`.
120
121## 5. Common Pitfalls
122
123- **Null columns:** use `sql.NullString`/`sql.NullInt64` or pointer
124 fields (`*string`, nil = SQL NULL). Scanning NULL into a plain string
125 errors at runtime.
126- **N+1 queries:** a query inside a loop over query results. Replace
127 with a JOIN or a batch query (`WHERE id = ANY($1)`).
128- **Connection leaks:** any early `return` between `Query` and
129 `defer rows.Close()` leaks a connection from the pool.
130
131Worked examples of each pitfall: `references/query-patterns.md`.
132
133## Verification Checklist
134
1351. Connection pool configured with explicit limits (`MaxOpenConns`, `MaxIdleConns`, lifetimes)
1362. All queries use parameterized placeholders, never string concatenation
1373. All `QueryContext` results have `defer rows.Close()` immediately after error check
1384. `rows.Err()` checked after row iteration loop
1395. `sql.ErrNoRows` handled explicitly with `errors.Is`
1406. Transactions use a helper that guarantees rollback on error
1417. Context propagated to all database calls (`*Context` variants)
1428. Nullable columns use `sql.NullString` / `sql.NullInt64` or pointer types
1439. No N+1 query patterns — use JOINs or batch queries
14410. Migrations are versioned, reversible, and run separately from app startup