Go Database Patterns
Senior data engineer implementing robust database access in Go with connection pooling, transaction management, repository patterns, and migration strategies. This skill covers both SQL and NoSQL data stores.
TL;DR Checklist
- Always configure
MaxOpenConns,MaxIdleConns, andConnMaxLifetimeonsql.DB - Use
*sql.DB(pool) — never*sql.Connfor general access - Wrap all SQL operations in
defer tx.Rollback()unless committed - Use
context.WithTimeoutfor all database queries — never run unbounded queries - Map database errors to domain errors (unique constraint →
ErrConflict) - Version migrations and make them idempotent
When to Use
Use this skill when:
- Setting up database connections with proper connection pooling
- Implementing repository patterns for data access abstraction
- Managing transactions with proper rollback and error handling
- Designing database migrations for production deployments
- Caching database queries to reduce load
When NOT to Use
Avoid this skill for:
- Simple in-memory data structures (no database needed)
- Read-heavy analytics workloads (use a data warehouse instead)
- Applications that only need simple file-based storage
Core Workflow
Configure Connection Pool — Set pool parameters based on expected workload and database capacity. Checkpoint: Pool size matches the database's max connections — don't exhaust the DB.
Implement Repository Interface — Define repository interfaces in the domain package. Checkpoint: Repository methods accept and return domain types, not database rows.
Handle Transactions Correctly — Use
db.BeginTx()with context for transactional operations. Checkpoint: Every transaction has adefer tx.Rollback()and explicittx.Commit().Map Database Errors to Domain Errors — Translate DB-specific errors into domain error types. Checkpoint:
errors.Is()works with domain error types for error checking.Manage Migrations — Version and apply database schema changes with rollback support. Checkpoint: Migrations are idempotent and can be safely re-run.
Implementation Patterns
Pattern 1: Connection Pool Configuration (❌ BAD vs ✅ GOOD)
Proper connection pooling prevents resource exhaustion and ensures consistent performance.
❌ BAD — Default Connection Pool
// ❌ BAD: default connection pool — unlimited connections, no lifetime management
func main() {
db, err := sql.Open("postgres", "postgres://localhost/myapp")
if err != nil {
log.Fatal(err)
}
// No pool configuration — unlimited open connections, connections never recycled
// Under load, this can exhaust the database server's max_connections
// Query without context — can hang forever
row := db.QueryRow("SELECT * FROM users WHERE id = $1", 1)
}
What's wrong:
- Default pool has unlimited
MaxOpenConns— can exhaust database connections - No
ConnMaxLifetime— dead connections accumulate, causing errors - No
MaxIdleConns— idle connections waste resources - Query has no context/timeout — hangs forever on a slow query
- No error handling for
sql.Open(which doesn't actually connect)
✅ GOOD — Production-Ready Connection Pool
// setupDB opens a database connection with production-ready pool configuration.
// Call db.Ping() to verify connectivity after opening.
func setupDB(ctx context.Context, dsn string) (*sql.DB, error) {
db, err := sql.Open("postgres", dsn)
if err != nil {
return nil, fmt.Errorf("open database: %w", err)
}
// Configure connection pool
db.SetMaxOpenConns(25) // max concurrent connections
db.SetMaxIdleConns(10) // idle connections to keep open
db.SetConnMaxLifetime(5 * time.Minute) // recycle connections to avoid stale state
db.SetConnMaxIdleTime(2 * time.Minute) // close idle connections after 2 minutes
// Verify connectivity (sql.Open doesn't actually connect)
if err := db.PingContext(ctx); err != nil {
db.Close()
return nil, fmt.Errorf("ping database: %w", err)
}
return db, nil
}
// getUser retrieves a user by ID with proper timeout and error handling.
func getUser(ctx context.Context, db *sql.DB, id string) (*User, error) {
// Always use context-aware query methods
row := db.QueryRowContext(ctx,
"SELECT id, name, email, created_at FROM users WHERE id = $1", id,
)
var user User
var createdAt time.Time
err := row.Scan(&user.ID, &user.Name, &user.Email, &createdAt)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("get user: user %s not found", id)
}
return nil, fmt.Errorf("get user: %w", err)
}
user.CreatedAt = createdAt
return &user, nil
}
Why this works:
MaxOpenConns: 25limits concurrent connections to a safe numberConnMaxLifetime: 5mrecycles connections before they become staleConnMaxIdleTime: 2mreleases idle connections back to the poolPingContext()verifies actual connectivity at startupQueryRowContext()respects context timeout — no hung queries
Pattern 2: Transaction Management (❌ BAD vs ✅ GOOD)
Transactions must be atomic, with proper rollback on any failure.
❌ BAD — Manual Transaction Management
// ❌ BAD: manual transaction management — easy to forget rollback, race conditions
func transferFunds(fromID, toID string, amount float64) error {
// No context — transaction can hang forever
tx, err := db.Begin() // auto-commit if not used
if err != nil {
return err
}
// No defer tx.Rollback() — if this function returns early, transaction leaks
// Deduct from source
_, err = tx.Exec("UPDATE accounts SET balance = balance - $1 WHERE id = $2", amount, fromID)
if err != nil {
return err // tx is never rolled back — connection leak!
}
// Credit destination
_, err = tx.Exec("UPDATE accounts SET balance = balance + $1 WHERE id = $2", amount, toID)
if err != nil {
return err // tx is never rolled back — connection leak!
}
return tx.Commit() // only commits if both queries succeed
}
What's wrong:
- No
defer tx.Rollback()— failed queries leave transactions open, leaking connections - No context — transaction hangs forever on a slow query
- Errors are returned without rollback — data is in an inconsistent state
- No isolation level specified — default may not be appropriate
- No check for zero/negative amount — business logic bug
✅ GOOD — Structured Transaction with Rollback
// TransferFunds moves funds between two accounts atomically.
// Returns ErrInsufficientFunds if the source account lacks sufficient balance.
// Uses SERIALIZABLE isolation to prevent race conditions on balance checks.
func TransferFunds(ctx context.Context, db *sql.DB, fromID, toID string, amount float64) error {
// Guard clauses
if amount <= 0 {
return fmt.Errorf("transfer funds: amount must be positive, got %f", amount)
}
if fromID == toID {
return fmt.Errorf("transfer funds: from and to accounts must differ")
}
// Begin transaction with explicit isolation level
tx, err := db.BeginTx(ctx, &sql.TxOptions{
Isolation: sql.LevelSerializable,
})
if err != nil {
return fmt.Errorf("transfer funds: begin transaction: %w", err)
}
// ALWAYS defer rollback — it's a no-op if already committed
defer func() {
if err != nil {
_ = tx.Rollback() // ignore rollback error if commit already happened
}
}()
// Deduct from source account (with SELECT FOR UPDATE to prevent races)
var balance float64
err = tx.QueryRowContext(ctx,
"SELECT balance FROM accounts WHERE id = $1 FOR UPDATE", fromID,
).Scan(&balance)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("transfer funds: source account %s not found", fromID)
}
return fmt.Errorf("transfer funds: check source balance: %w", err)
}
if balance < amount {
return fmt.Errorf("transfer funds: insufficient funds (have %.2f, need %.2f)", balance, amount)
}
// Deduct
if _, err = tx.ExecContext(ctx,
"UPDATE accounts SET balance = balance - $1 WHERE id = $2", amount, fromID,
); err != nil {
return fmt.Errorf("transfer funds: deduct from source: %w", err)
}
// Credit destination
if _, err = tx.ExecContext(ctx,
"UPDATE accounts SET balance = balance + $1 WHERE id = $2", amount, toID,
); err != nil {
return fmt.Errorf("transfer funds: credit destination: %w", err)
}
// Commit — if this fails, defer rollback runs
if err = tx.Commit(); err != nil {
return fmt.Errorf("transfer funds: commit: %w", err)
}
return nil
}
Why this works:
defer tx.Rollback()ensures rollback on any return path (including errors)BeginTxwithLevelSerializableprevents race conditions on concurrent transfersSELECT ... FOR UPDATElocks the row, preventing duplicate deductions- Guard clauses validate input before touching the database
- Every step has a specific error message for debugging
ctxpropagates through all queries — transaction respects context timeout
Pattern 3: Repository Pattern (❌ BAD vs ✅ GOOD)
The repository pattern abstracts database details behind an interface defined in the domain.
❌ BAD — Direct Database Access
// ❌ BAD: business logic contains raw SQL — mixed concerns, hard to test
type UserService struct {
db *sql.DB
}
func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
// SQL query mixed into business logic
row := s.db.QueryRowContext(ctx,
"SELECT u.id, u.name, u.email, u.created_at, r.name as role_name "+
"FROM users u LEFT JOIN roles r ON u.role_id = r.id WHERE u.id = $1", id,
)
var u User
var role string
var createdAt time.Time
err := row.Scan(&u.ID, &u.Name, &u.Email, &createdAt, &role)
if err != nil {
return nil, err
}
u.CreatedAt = createdAt
u.Role = role
return &u, nil
}
What's wrong:
- SQL query is embedded in business logic — can't test without a real database
- Query joins multiple tables — changes require updating business logic
- No abstraction — repository method signature leaks SQL details
- Cannot substitute a mock for testing
- Error handling is minimal —
erris returned without context
✅ GOOD — Repository Abstraction
// package: internal/user
// UserRepository defines the contract for user persistence.
// Implemented by infrastructure; consumed by domain logic.
type UserRepository interface {
GetByID(ctx context.Context, id string) (*User, error)
Create(ctx context.Context, user *User) error
Update(ctx context.Context, user *User) error
Delete(ctx context.Context, id string) error
List(ctx context.Context, page, limit int) ([]*User, error)
}
// Service uses the repository interface — never the database directly.
type Service struct {
repo UserRepository
}
// GetUser retrieves a user by ID.
func (s *Service) GetUser(ctx context.Context, id string) (*User, error) {
if id == "" {
return nil, fmt.Errorf("get user: id must not be empty")
}
user, err := s.repo.GetByID(ctx, id)
if err != nil {
return nil, fmt.Errorf("get user: %w", err)
}
return user, nil
}
// package: internal/infrastructure/db/postgres
// userRepository implements user.UserRepository using PostgreSQL.
type userRepository struct {
db *sql.DB
}
// NewUserRepository creates a PostgreSQL user repository.
func NewUserRepository(db *sql.DB) user.UserRepository {
return &userRepository{db: db}
}
// GetByID retrieves a user by their ID from PostgreSQL.
func (r *userRepository) GetByID(ctx context.Context, id string) (*user.User, error) {
row := r.db.QueryRowContext(ctx,
`SELECT u.id, u.name, u.email, u.created_at, u.updated_at,
COALESCE(r.name, '') as role_name
FROM users u
LEFT JOIN roles r ON u.role_id = r.id
WHERE u.id = $1`, id,
)
var u user.User
var createdAt, updatedAt time.Time
var roleName string
err := row.Scan(&u.ID, &u.Name, &u.Email, &createdAt, &updatedAt, &roleName)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("get user by id: user %s not found", id)
}
return nil, fmt.Errorf("get user by id: %w", err)
}
u.CreatedAt = createdAt
u.UpdatedAt = updatedAt
u.Role = roleName
return &u, nil
}
Why this works:
UserRepositoryinterface is defined in the domain package — infrastructure implements itServicedepends on the interface — testable with anyUserRepositoryimplementation- SQL is encapsulated in the repository — business logic is database-agnostic
- Easy to test: create
mockUserRepositorythat returns pre-defined data - Error wrapping preserves the error chain from infrastructure to domain
Constraints
MUST DO
- MUST configure connection pool (
MaxOpenConns,MaxIdleConns,ConnMaxLifetime) on everysql.DBinstance - MUST use
context.Contextfor ALL database operations — never omit context - MUST use
defer tx.Rollback()on every transaction — it is safe to call afterCommit() - MUST use
db.BeginTx(ctx, &sql.TxOptions{Isolation: ...})for transactional operations - MUST map database-specific errors to domain error types (e.g., unique constraint →
ErrConflict) - MUST validate input before executing database queries (guard clauses)
MUST NOT DO
- MUST NOT use
sql.Openwithout callingPingContext()— open doesn't verify connectivity - MUST NOT use string concatenation for SQL queries — always use parameterized queries
- MUST NOT store
*sql.DBin global variables — pass it as a dependency - MUST NOT use
tx.Exec()withoutdefer tx.Rollback() - MUST NOT assume
sql.Opensucceeds — always check the returned error - MUST NOT run queries without a context timeout in production
Related Skills
| Skill | Purpose |
|---|---|
best-practices |
Error handling, naming, and Go idioms |
cloud-development |
Configuration management for database connection strings |
modular-design |
Repository pattern integration with clean architecture |
testing-strategies |
Testing database code with mocks and test containers |
Live References
Authoritative documentation links for this skill's domain. The model follows markdown links at load time to resolve external references and inline content.