Go Database Patterns
Database access is where most Go services spend their complexity budget. Get connection management, transactions, and query patterns right.
1. Connection Management
Configure the connection pool explicitly:
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
}Pool sizing rules:
| 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 |
// ❌ Bad — unlimited connections (default)
db, _ := sql.Open("postgres", dsn)
// No pool config → unbounded connections → DB overload under loadAlways pass context to database operations:
// ✅ Good — context propagated
row := db.QueryRowContext(ctx, "SELECT id, name FROM users WHERE id = $1", id)
// ❌ Bad — no context, no cancellation support
row := db.QueryRow("SELECT id, name FROM users WHERE id = $1", id)2. Query Patterns
Use parameterized queries — NEVER string concatenation:
// ✅ Good — parameterized
rows, err := db.QueryContext(ctx,
"SELECT id, name FROM users WHERE status = $1 AND created_at > $2",
status, since,
)
// ❌ Bad — SQL injection vulnerability
rows, err := db.QueryContext(ctx,
fmt.Sprintf("SELECT id, name FROM users WHERE status = '%s'", status),
)Always close rows:
rows, err := db.QueryContext(ctx, query, args...)
if err != nil {
return fmt.Errorf("query users: %w", err)
}
defer rows.Close()
var users []User
for rows.Next() {
var u User
if err := rows.Scan(&u.ID, &u.Name, &u.Email); err != nil {
return fmt.Errorf("scan user: %w", err)
}
users = append(users, u)
}
// ALWAYS check rows.Err() after iteration
if err := rows.Err(); err != nil {
return fmt.Errorf("iterate users: %w", err)
}Use QueryRowContext for single-row queries:
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)
}3. Transactions
Use a transaction helper to ensure 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
}Usage:
err := WithTx(ctx, db, func(tx *sql.Tx) error {
if _, err := tx.ExecContext(ctx,
"UPDATE accounts SET balance = balance - $1 WHERE id = $2", amount, fromID,
); err != nil {
return fmt.Errorf("debit: %w", err)
}
if _, err := tx.ExecContext(ctx,
"UPDATE accounts SET balance = balance + $1 WHERE id = $2", amount, toID,
); err != nil {
return fmt.Errorf("credit: %w", err)
}
return nil
})Set appropriate isolation levels:
tx, err := db.BeginTx(ctx, &sql.TxOptions{
Isolation: sql.LevelSerializable, // for critical financial operations
})4. Repository Pattern
Define a repository interface at the consumer side:
type UserRepository interface {
GetByID(ctx context.Context, id string) (*User, error)
List(ctx context.Context, filter UserFilter) ([]User, error)
Create(ctx context.Context, user *User) error
Update(ctx context.Context, user *User) error
Delete(ctx context.Context, id string) error
}Implement with concrete database access:
type pgUserRepo struct {
db *sql.DB
}
func NewUserRepository(db *sql.DB) UserRepository {
return &pgUserRepo{db: db}
}
func (r *pgUserRepo) GetByID(ctx context.Context, id string) (*User, error) {
var u User
err := r.db.QueryRowContext(ctx,
"SELECT id, name, email, created_at FROM users WHERE id = $1", id,
).Scan(&u.ID, &u.Name, &u.Email, &u.CreatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUserNotFound
}
if err != nil {
return nil, fmt.Errorf("get user %s: %w", id, err)
}
return &u, nil
}5. sqlc — Type-Safe SQL
Prefer sqlc for projects that use raw SQL. It generates type-safe Go code from SQL queries.
Write SQL queries with annotations:
-- name: GetUser :one
SELECT id, name, email, created_at
FROM users
WHERE id = $1;
-- name: ListUsers :many
SELECT id, name, email, created_at
FROM users
WHERE status = $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3;
-- name: CreateUser :one
INSERT INTO users (name, email)
VALUES ($1, $2)
RETURNING id, name, email, created_at;sqlc generates Go code with proper types, eliminating manual Scan calls and catching query/schema mismatches at build time.
6. Migrations
Use a migration tool — never manual DDL:
Recommended tools: goose, golang-migrate, atlas.
Migration rules:
- One migration per schema change
- Migrations are forward-only in production — never edit applied migrations
- Include both
upanddown(rollback) SQL - Test migrations against a copy of production data before deploying
- Keep migrations small and reversible
-- +goose Up
ALTER TABLE users ADD COLUMN phone VARCHAR(20);
-- +goose Down
ALTER TABLE users DROP COLUMN phone;Run migrations at startup or as a separate step, not both:
// ✅ Good — separate migration command
// cmd/migrate/main.go runs migrations
// cmd/server/main.go starts the server
// ❌ Bad — migrations in server startup
func main() {
runMigrations(db) // blocks startup, risky in multi-instance deploys
startServer()
}7. Common Pitfalls
Null handling:
// ✅ Good — use sql.Null types or pointers
type User struct {
ID string
Name string
Phone sql.NullString // nullable column
}
// Or with pointers:
type User struct {
ID string
Name string
Phone *string // nil = SQL NULL
}Avoiding N+1 queries:
// ❌ Bad — N+1 query pattern
users, _ := listUsers(ctx)
for _, u := range users {
orders, _ := getOrdersByUser(ctx, u.ID) // 1 query per user
u.Orders = orders
}
// ✅ Good — single query with JOIN or batch
users, _ := listUsersWithOrders(ctx) // JOIN or subqueryConnection leak prevention:
// ❌ Bad — rows not closed on early return
rows, err := db.QueryContext(ctx, query)
if err != nil {
return err
}
// forgot defer rows.Close()
if someCondition {
return nil // rows leaked!
}Verification Checklist
- Connection pool configured with explicit limits (
MaxOpenConns,MaxIdleConns, lifetimes) - All queries use parameterized placeholders, never string concatenation
- All
QueryContextresults havedefer rows.Close()immediately after error check rows.Err()checked after row iteration loopsql.ErrNoRowshandled explicitly witherrors.Is- Transactions use a helper that guarantees rollback on error
- Context propagated to all database calls (
*Contextvariants) - Nullable columns use
sql.NullString/sql.NullInt64or pointer types - No N+1 query patterns — use JOINs or batch queries
- Migrations are versioned, reversible, and run separately from app startup