| name | go-database |
| description | > Use when this capability is needed. |
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 |
db, _ := sql.Open("postgres", dsn)
Always pass context to database operations:
row := db.QueryRowContext(ctx, "SELECT id, name FROM users WHERE id = $1", id)
row := db.QueryRow("SELECT id, name FROM users WHERE id = $1", id)
2. Query Patterns
Use parameterized queries — NEVER string concatenation:
rows, err := db.QueryContext(ctx,
"SELECT id, name FROM users WHERE status = $1 AND created_at > $2",
status, since,
)
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)
}
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,
})
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:
SELECT id, name, email, created_at
FROM users
WHERE id = $1;
SELECT id, name, email, created_at
FROM users
WHERE status = $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3;
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
up and down (rollback) SQL
- Test migrations against a copy of production data before deploying
- Keep migrations small and reversible
ALTER TABLE users ADD COLUMN phone VARCHAR(20);
ALTER TABLE users DROP COLUMN phone;
Run migrations at startup or as a separate step, not both:
func main() {
runMigrations(db)
startServer()
}
7. Common Pitfalls
Null handling:
type User struct {
ID string
Name string
Phone sql.NullString
}
type User struct {
ID string
Name string
Phone *string
}
Avoiding N+1 queries:
users, _ := listUsers(ctx)
for _, u := range users {
orders, _ := getOrdersByUser(ctx, u.ID)
u.Orders = orders
}
users, _ := listUsersWithOrders(ctx)
Connection leak prevention:
rows, err := db.QueryContext(ctx, query)
if err != nil {
return err
}
if someCondition {
return nil
}
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
Source: eduardo-sl/go-agent-skills — distributed by TomeVault.