← Files GophersARCHIVED FILE

skills/go-database/references/anti-patterns.md

4.8 KB · Oct 5, 2026 · 18:31 UTC

↓ Download file

# Database Anti-Patterns

## 1. String-Concatenated Queries

```go
// Bad — classic SQL injection.
q := "SELECT * FROM users WHERE email = '" + email + "'"
db.QueryContext(ctx, q)
```

Even with "trusted" inputs, the next refactor passes user input through the same path. Always use placeholders.

```go
db.GetContext(ctx, &u, "SELECT id FROM users WHERE email = $1", email)
```

## 2. `db.Query` Without Context

```go
// Bad — no timeout; cannot be canceled.
rows, _ := db.Query("SELECT ...")
```

When the client disconnects, the query runs to completion against the DB anyway, holding a pool connection.

```go
rows, err := db.QueryContext(ctx, "SELECT ...")
```

## 3. Forgetting `rows.Close()`

```go
// Bad — connection returned to the pool only when the rows are garbage-collected.
rows, _ := db.QueryContext(ctx, "SELECT ...")
for rows.Next() { ... }
```

Under load, the pool exhausts; subsequent queries block. Defer immediately.

```go
rows, err := db.QueryContext(ctx, "SELECT ...")
if err != nil { return err }
defer rows.Close()
```

## 4. Missing `rows.Err()`

```go
// Bad — iteration may have stopped early due to network error.
for rows.Next() {
    _ = rows.Scan(&u)
    out = append(out, u)
}
return out, nil  // ← caller thinks the list is complete
```

```go
if err := rows.Err(); err != nil { return nil, err }
return out, nil
```

## 5. `db.Query` for INSERT/UPDATE/DELETE

`Query` returns `*Rows` that must be closed; if you don't, the connection leaks. `Exec` returns `Result` and releases the connection on return.

```go
// Bad
db.Query("UPDATE users SET active = false WHERE id = $1", id)

// Good
db.ExecContext(ctx, "UPDATE users SET active = false WHERE id = $1", id)
```

## 6. Returning `*sql.DB` from Repositories

```go
// Bad — every caller knows it's database/sql.
type UserRepo struct{}
func (r *UserRepo) DB() *sql.DB { return r.db }
```

Now swapping to pgx requires touching every caller. Repositories should return domain types and accept `context.Context` — the underlying driver stays private.

## 7. ORM Hooks for Business Logic

```go
// Bad — BeforeSave fires a notification, but only sometimes (depending on bulk vs single save).
func (u *User) BeforeSave(tx *gorm.DB) error {
    notify("user created")
    return nil
}
```

State machines living in framework lifecycle hooks are invisible at the call site. Move logic into a service function with explicit steps.

## 8. Unlimited Connection Pool

```go
// Bad — Go default is unlimited; a load spike exhausts pg_max_connections.
db, _ := sql.Open("postgres", dsn)
```

```go
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(10)
db.SetConnMaxLifetime(5 * time.Minute)
```

A reasonable starting point: `MaxOpenConns ≤ pg_max_connections / replica_count - headroom`.

## 9. External Calls Inside a Transaction

```go
// Bad — Stripe call inside the tx holds the row lock for seconds.
withTx(ctx, db, func(tx *sqlx.Tx) (struct{}, error) {
    tx.ExecContext(ctx, "UPDATE orders ...")
    stripe.Capture(ctx, id)
    return struct{}{}, nil
})
```

Do the external work first; record the result in a short transaction afterwards. See [transactions.md](transactions.md).

## 10. ORM-Generated Migrations

```go
db.AutoMigrate(&User{})
```

The ORM picks index strategies, types, and constraints based on Go tags. A column type that's "fine" on toy data may cause table rewrites on production. Indexes that the ORM omits cause table scans.

Write migrations as SQL, version them with `golang-migrate` or Atlas, review them as humans.

## 11. Scanning NULL into Non-Pointer

```go
type User struct {
    Bio string `db:"bio"` // ← bio can be NULL
}
db.GetContext(ctx, &u, "SELECT id, bio FROM users WHERE id = $1", id)
// → "sql: Scan error: converting NULL to string is unsupported"
```

Use `*string` or `sql.NullString`.

## 12. Wildcard `SELECT *`

```go
db.SelectContext(ctx, &users, "SELECT * FROM users")
```

Adding a column to the table now breaks scanning, and the binary ships unnecessary bytes. Spell out columns:

```go
db.SelectContext(ctx, &users, "SELECT id, email, name, created_at FROM users")
```

## 13. Reusing the Same `*sql.Tx` Across Goroutines

`*sql.Tx` is not safe for concurrent use. Spawning goroutines that share a tx will silently produce errors or use different connections from the pool. Run statements sequentially within a tx.

## 14. Logging the SQL String at Info Level

In production, the SQL string contains personally-identifying data (email, IDs) once the placeholder is substituted by the driver. Log query templates and parameter shapes, not values. (See go-logging skill.)

## 15. ORM `Find` Without Pagination

```go
var users []User
db.Find(&users) // ← all users, no LIMIT
```

A "small" table at launch becomes a 5M-row table 18 months later; the same line now OOMs the process. Always paginate list queries, even when you "know" the data is small.

SHA-256: 2231b7110a75a6785a4bccc0e180b8dc5c74c7910579f0b6284cef77e80399da