Faisalaffan Blog
|
🇬🇧
Faisalaffan Blog
Engineering27 min read

20 Database Problems That Make Senior Engineers Break a Cold Sweat in Production

N+1 is just the tip of the iceberg. Beneath it lie Cartesian Explosion, Lost Update, God Table, Replication Lag, ORM Trap, and 15 other far more dangerous problems — complete with real-world case studies and Golang solutions.

Faisal Affan
Faisal Affan
Published
Gallery
1 / 3

20 Database Problems That Make Senior Engineers Break a Cold Sweat in Production

"Every time a production system suddenly slows down for no apparent reason, the database is always the prime suspect — and almost always guilty."

TL;DR

N+1 queries are like pimples — easy to spot, everyone knows how to treat them. The dangerous ones are the internal diseases that go undetected until the system collapses in production. This article covers 20 database problems across 5 different categories, each with real-world case studies and practical Golang solutions.


Map of Database Problems

Database problems aren't just about slow queries. There are 5 major categories to understand:


PART 1: Query Latency Problems

1. Cartesian Product Explosion (Hidden Cross Join)

What's the Problem?

Occurs when two tables are JOINed without a proper condition. The result: the row count of both tables multiplies, potentially reaching millions of rows in seconds.

Case Study

An e-commerce startup with products (50,000 rows) and tags (200 rows):

SQL
-- Looks innocent, but this is a time bomb
SELECT p.*, t.*
FROM products p, tags t
WHERE p.category = 'electronics';
-- Result: 50,000 × 200 = 10,000,000 rows

Database server crashed within 3 minutes after the feature was deployed.

Solution in Golang

Go
// ❌ Dangerous: implicit cross join
func GetProductsWithTagsBad(db *sql.DB) ([]ProductTag, error) {
    rows, err := db.Query(`
        SELECT p.id, p.name, t.name as tag_name
        FROM products p, tags t
        WHERE p.category = 'electronics'
    `)
    return scanRows(rows, err)
}

// ✅ Correct: explicit INNER JOIN with condition
func GetProductsWithTagsGood(db *sqlx.DB, category string, limit, offset int) ([]Product, error) {
    var products []Product
    err := db.Select(&products, `
        SELECT p.id, p.name, p.price
        FROM products p
        INNER JOIN product_tags pt ON pt.product_id = p.id
        INNER JOIN tags t ON t.id = pt.tag_id
        WHERE p.category = $1
        ORDER BY p.id
        LIMIT $2 OFFSET $3
    `, category, limit, offset)
    if err != nil {
        return nil, fmt.Errorf("get products: %w", err)
    }

    productIDs := extractIDs(products)
    tags, err := getTagsByProductIDs(db, productIDs)
    if err != nil {
        return nil, err
    }
    return mergeProductTags(products, tags), nil
}
Rule of Thumb

Every time you write a JOIN without an explicit ON, always ask: "What's the maximum number of rows this could return?" If you can't answer with certainty — there's a problem.


2. Unbounded Query (Query Without LIMIT)

What's the Problem?

A query without LIMIT fetches all matching data. In development with 100 rows, it's unnoticeable. In production with 5 million rows? Gigabytes of memory gone in an instant.

Case Study

A digital content platform with 3 million articles. The "export all articles" feature worked fine for 2 years. In year 3, a single click of the export button caused 45 minutes of downtime due to server OOM.

Solution in Golang

Go
// ✅ Cursor-based streaming for large data exports
func ExportArticlesCursor(ctx context.Context, db *sql.DB, batchSize int) (<-chan Article, <-chan error) {
    articles := make(chan Article, batchSize)
    errc := make(chan error, 1)

    go func() {
        defer close(articles)
        defer close(errc)

        lastID := 0
        for {
            batch, err := fetchBatch(ctx, db, lastID, batchSize)
            if err != nil {
                errc <- err
                return
            }
            if len(batch) == 0 {
                return
            }
            for _, article := range batch {
                select {
                case articles <- article:
                case <-ctx.Done():
                    errc <- ctx.Err()
                    return
                }
            }
            lastID = batch[len(batch)-1].ID
        }
    }()

    return articles, errc
}

func fetchBatch(ctx context.Context, db *sql.DB, afterID, limit int) ([]Article, error) {
    rows, err := db.QueryContext(ctx, `
        SELECT id, title, content, status, created_at
        FROM articles
        WHERE status = 'published' AND id > $1
        ORDER BY id ASC
        LIMIT $2
    `, afterID, limit)
    if err != nil {
        return nil, err
    }
    defer rows.Close()
    return scanArticles(rows)
}

3. Missing Index Trap (Hidden Sequential Scan)

What's the Problem?

Filtering a query on a column without an index forces the database to read every row. This is O(n) — the larger the table, the slower it gets, linearly.

Case Study

A fintech company with a transactions table (50 million rows). Querying transaction history without an index: 8 seconds. After adding an index: 12ms. A 666x difference.

Solution in Golang

Go
// ✅ Detect missing indexes with EXPLAIN ANALYZE
func AnalyzeQuery(ctx context.Context, db *sql.DB, query string, args ...interface{}) error {
    explainQuery := "EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) " + query
    var result []byte
    if err := db.QueryRowContext(ctx, explainQuery, args...).Scan(&result); err != nil {
        return err
    }
    if strings.Contains(string(result), "Seq Scan") {
        log.Warn().Str("query", query).Msg("Sequential scan detected — consider adding index")
    }
    return nil
}

// ✅ Proper indexes for common query patterns
/*
-- Composite index for per-user transaction queries
CREATE INDEX CONCURRENTLY idx_transactions_user_created
ON transactions(user_id, created_at DESC);

-- Partial index for specific statuses only
CREATE INDEX CONCURRENTLY idx_transactions_pending
ON transactions(created_at) WHERE status = 'pending';
*/

// ✅ Query using composite index with cursor pagination
func GetUserTransactions(ctx context.Context, db *sql.DB, userID, limit int, cursor *time.Time) ([]Transaction, error) {
    if cursor == nil {
        rows, err := db.QueryContext(ctx, `
            SELECT id, user_id, amount, status, created_at
            FROM transactions
            WHERE user_id = $1
            ORDER BY created_at DESC LIMIT $2
        `, userID, limit)
        defer rows.Close()
        return scanTransactions(rows, err)
    }
    rows, err := db.QueryContext(ctx, `
        SELECT id, user_id, amount, status, created_at
        FROM transactions
        WHERE user_id = $1 AND created_at < $2
        ORDER BY created_at DESC LIMIT $3
    `, userID, cursor, limit)
    defer rows.Close()
    return scanTransactions(rows, err)
}
Detection Tools

Enable pg_stat_statements in PostgreSQL to see the slowest queries in production. Integrate OpenTelemetry in Golang to auto-trace every query with its duration.


4. Over-fetching (SELECT * Syndrome)

What's the Problem?

SELECT * fetches all columns including large ones (BLOB, JSONB, long TEXT) that aren't needed. Network I/O and memory usage bloat for nothing.

Case Study

A /users/list API for a name dropdown. The users table has a profile_photo column (BYTEA, ~2MB) and settings column (JSONB, ~50KB). For 100 users: 205MB transferred from the DB — when all that's needed is id + name = ~2KB.

Solution in Golang

Go
// ❌ Anti-pattern: fetch all columns
func GetAllUsersBad(db *sqlx.DB) ([]User, error) {
    var users []User
    return users, db.Select(&users, "SELECT * FROM users")
}

// ✅ Use a specific DTO per use case
type UserListItem struct {
    ID   int    `db:"id"`
    Name string `db:"name"`
}

func GetUserListForDropdown(db *sqlx.DB) ([]UserListItem, error) {
    var users []UserListItem
    return users, db.Select(&users, `
        SELECT id, name FROM users
        WHERE active = true ORDER BY name LIMIT 500
    `)
}

5. Implicit Type Casting (Index Invalidation)

What's the Problem?

When the parameter type doesn't match the column type, the database is forced to cast — and the index on that column becomes unusable.

Case Study

A phone_number column of type VARCHAR with an index. The developer sends an int64 as a parameter. PostgreSQL has to cast every value to an integer — the query goes from 1ms to 8 seconds.

Solution in Golang

Go
// ❌ Wrong: type doesn't match VARCHAR column
func GetUserByPhoneBad(db *sql.DB, phone int64) (*User, error) {
    row := db.QueryRow("SELECT id, name FROM users WHERE phone_number = $1", phone)
    var u User
    return &u, row.Scan(&u.ID, &u.Name)
}

// ✅ Correct: use string matching the column type
type PhoneNumber string

func GetUserByPhoneGood(db *sql.DB, phone PhoneNumber) (*User, error) {
    row := db.QueryRow("SELECT id, name FROM users WHERE phone_number = $1", string(phone))
    var u User
    return &u, row.Scan(&u.ID, &u.Name)
}
Pro Tip

WHERE created_at::date = '2026-01-01' is just as dangerous. Casting in a WHERE clause always invalidates indexes. Use: WHERE created_at >= '2026-01-01' AND created_at < '2026-01-02'.


6. Long-Running Transaction Lock

What's the Problem?

A long-running transaction blocks all other operations on the same rows. In a concurrent system, this creates a growing queue until mass timeout.

Case Study

A concert ticketing platform. A ticket purchase transaction calls an external API (2 seconds) plus a payment gateway (3 seconds) inside a single database transaction. Result: massive deadlock, server unresponsive within 10 minutes.

Solution in Golang

Go
// ❌ Anti-pattern: external calls inside a DB transaction
func BuyTicketBad(ctx context.Context, db *sql.DB, userID, ticketID int) error {
    tx, _ := db.BeginTx(ctx, nil)
    defer tx.Rollback()
    tx.ExecContext(ctx, "UPDATE tickets SET status='reserved' WHERE id=$1", ticketID)
    validateUserWithExternalAPI(userID)  // ❌ 2 second lock!
    processPayment(userID, ticketID)     // ❌ 3 second lock!
    return tx.Commit()
}

// ✅ Separate: validate → short lock → payment outside transaction
func BuyTicketGood(ctx context.Context, db *sql.DB, userID, ticketID int) error {
    // Step 1: Validate BEFORE opening a transaction
    if err := validateUserWithExternalAPI(userID); err != nil {
        return fmt.Errorf("user validation: %w", err)
    }

    // Step 2: Short DB transaction — DB operations only
    reservationID, err := reserveTicketSkipLocked(ctx, db, userID, ticketID)
    if err != nil {
        return fmt.Errorf("reserve ticket: %w", err)
    }

    // Step 3: Payment AFTER the DB transaction completes
    if err := processPayment(userID, reservationID); err != nil {
        _ = cancelReservation(ctx, db, reservationID) // compensating transaction
        return fmt.Errorf("process payment: %w", err)
    }

    return confirmTicket(ctx, db, reservationID)
}

// ✅ SELECT FOR UPDATE SKIP LOCKED for high-concurrency scenarios
func reserveTicketSkipLocked(ctx context.Context, db *sql.DB, userID, ticketID int) (int, error) {
    var id int
    err := db.QueryRowContext(ctx, `
        WITH locked AS (
            SELECT id FROM tickets
            WHERE id = $1 AND status = 'available'
            FOR UPDATE SKIP LOCKED LIMIT 1
        )
        UPDATE tickets SET status = 'reserved', reserved_by = $2, reserved_at = NOW()
        WHERE id IN (SELECT id FROM locked)
        RETURNING id
    `, ticketID, userID).Scan(&id)
    if err == sql.ErrNoRows {
        return 0, ErrTicketNotAvailable
    }
    return id, err
}

PART 2: Write-side Problems

7. Write Amplification (Index Overhead)

What's the Problem?

Every INSERT or UPDATE operation doesn't just write to the table — it also writes to every index on that table. A table with 15 indexes can generate 16 write operations for 1 INSERT.

Case Study

An events table in an analytics system with 20 indexes (accumulated over years of "just in case" additions). Initial INSERT throughput was 10,000/sec. After auditing and removing 12 unused indexes: 47,000/sec — nearly 5x faster.

Solution in Golang

Go
// ✅ Audit unused indexes in PostgreSQL
const unusedIndexQuery = `
    SELECT
        schemaname,
        tablename,
        indexname,
        idx_scan,
        pg_size_pretty(pg_relation_size(indexrelid)) AS index_size
    FROM pg_stat_user_indexes
    WHERE idx_scan = 0
      AND indexrelname NOT LIKE 'pg_%'
    ORDER BY pg_relation_size(indexrelid) DESC;
`

func AuditUnusedIndexes(ctx context.Context, db *sql.DB) error {
    rows, err := db.QueryContext(ctx, unusedIndexQuery)
    if err != nil {
        return err
    }
    defer rows.Close()

    log.Info().Msg("=== Unused Indexes (candidates for removal) ===")
    for rows.Next() {
        var schema, table, index, size string
        var scans int64
        rows.Scan(&schema, &table, &index, &scans, &size)
        log.Info().Str("index", index).Str("table", table).Str("size", size).Msg("unused index")
    }
    return rows.Err()
}

// ✅ For bulk inserts, use the COPY protocol via pgx (much faster)
func BulkInsertEvents(ctx context.Context, pool *pgxpool.Pool, events []Event) error {
    _, err := pool.CopyFrom(
        ctx,
        pgx.Identifier{"events"},
        []string{"user_id", "event_type", "payload", "created_at"},
        pgx.CopyFromSlice(len(events), func(i int) ([]interface{}, error) {
            return []interface{}{
                events[i].UserID,
                events[i].Type,
                events[i].Payload,
                events[i].CreatedAt,
            }, nil
        }),
    )
    return err
}

8. Hot Spot / Write Contention

What's the Problem?

Thousands of goroutines writing to the same row or table simultaneously. The database must serialize all these writes one by one — creating a single bottleneck.

Case Study

An online game leaderboard system. The total_score column in the global_stats table is updated every time a player completes a mission. When 5,000 concurrent players are active, this table becomes a hot spot — p99 latency spikes to 12 seconds.

Solution in Golang

Go
// ❌ Anti-pattern: direct update to a single row
func UpdateGlobalScoreBad(ctx context.Context, db *sql.DB, delta int) error {
    _, err := db.ExecContext(ctx, `
        UPDATE global_stats SET total_score = total_score + $1
    `, delta)
    return err
}

// ✅ Solution 1: Write buffer — accumulate updates, flush periodically
type ScoreBuffer struct {
    mu    sync.Mutex
    delta int
}

var buffer = &ScoreBuffer{}

func AddScore(delta int) {
    buffer.mu.Lock()
    buffer.delta += delta
    buffer.mu.Unlock()
}

func FlushScoreBuffer(ctx context.Context, db *sql.DB) error {
    buffer.mu.Lock()
    delta := buffer.delta
    buffer.delta = 0
    buffer.mu.Unlock()

    if delta == 0 {
        return nil
    }
    _, err := db.ExecContext(ctx, `
        UPDATE global_stats SET total_score = total_score + $1
    `, delta)
    return err
}

// ✅ Solution 2: Sharded counter — split one row into N rows
func UpdateShardedScore(ctx context.Context, db *sql.DB, userID, delta int) error {
    shardID := userID % 16 // 16 shards
    _, err := db.ExecContext(ctx, `
        INSERT INTO score_shards (shard_id, delta)
        VALUES ($1, $2)
    `, shardID, delta)
    return err
}

func GetTotalScore(ctx context.Context, db *sql.DB) (int64, error) {
    var total int64
    err := db.QueryRowContext(ctx, `
        SELECT COALESCE(SUM(delta), 0) FROM score_shards
    `).Scan(&total)
    return total, err
}

9. Upsert Race Condition

What's the Problem?

Two goroutines perform a "check then insert" simultaneously. Both check "data doesn't exist yet" at the same time — then both INSERT — and one fails with a duplicate key error or, worse, duplicate data enters without an error.

Case Study

A notification system. Two events arrive simultaneously for the same user. Both check WHERE user_id = X → nothing found → both INSERT. Result: the user receives duplicate notifications twice.

Solution in Golang

Go
// ❌ Anti-pattern: check-then-insert without atomicity
func CreateUserProfileBad(ctx context.Context, db *sql.DB, userID int) error {
    var count int
    db.QueryRowContext(ctx, "SELECT COUNT(*) FROM profiles WHERE user_id = $1", userID).Scan(&count)
    // Race condition window: another goroutine could insert here!
    if count == 0 {
        _, err := db.ExecContext(ctx, "INSERT INTO profiles (user_id) VALUES ($1)", userID)
        return err
    }
    return nil
}

// ✅ Use INSERT ... ON CONFLICT (atomic upsert)
func UpsertUserProfile(ctx context.Context, db *sql.DB, userID int, data ProfileData) error {
    _, err := db.ExecContext(ctx, `
        INSERT INTO profiles (user_id, bio, avatar_url, updated_at)
        VALUES ($1, $2, $3, NOW())
        ON CONFLICT (user_id) DO UPDATE SET
            bio = EXCLUDED.bio,
            avatar_url = EXCLUDED.avatar_url,
            updated_at = NOW()
    `, userID, data.Bio, data.AvatarURL)
    return err
}

// ✅ Or use INSERT ... ON CONFLICT DO NOTHING for idempotent inserts
func EnsureUserExists(ctx context.Context, db *sql.DB, userID int) error {
    _, err := db.ExecContext(ctx, `
        INSERT INTO users (id, created_at)
        VALUES ($1, NOW())
        ON CONFLICT (id) DO NOTHING
    `, userID)
    return err
}

PART 3: Data Consistency Problems

10. Dirty Read / Phantom Read (Wrong Isolation Level)

What's the Problem?

An isolation level that is too low allows a transaction to read uncommitted data (dirty read) or see new data that appears mid-transaction (phantom read).

Case Study

An accounting system. A financial report is generated using the READ UNCOMMITTED isolation level. The report shows a balance that never existed — because it read transactions that were later rolled back.

Solution in Golang

Go
// ✅ Set the appropriate isolation level for your needs
func GenerateFinancialReport(ctx context.Context, db *sql.DB) (*Report, error) {
    // Snapshot isolation: all reads from a consistent snapshot
    tx, err := db.BeginTx(ctx, &sql.TxOptions{
        Isolation: sql.LevelRepeatableRead, // or LevelSerializable for critical operations
        ReadOnly:  true,
    })
    if err != nil {
        return nil, err
    }
    defer tx.Rollback()

    // All queries here read from the snapshot at the time the tx started
    report, err := buildReport(ctx, tx)
    if err != nil {
        return nil, err
    }

    return report, tx.Commit()
}

/*
Isolation level guide:
- READ COMMITTED (PostgreSQL default): suitable for normal operations
- REPEATABLE READ: suitable for reports that need consistency within a single transaction
- SERIALIZABLE: suitable for critical financial operations, but slower
*/

11. Lost Update

What's the Problem?

Two transactions read the same value, both modify it, and the one that commits last overwrites the changes made by the first transaction. The first change is simply lost.

Case Study

Two admins editing the same user profile at the same time. Admin A changes the email, Admin B changes the name. Whoever saves last overwrites the other's changes — one update disappears without a trace.

Solution in Golang

Go
// ✅ Optimistic locking with version/updated_at column
type UserProfile struct {
    ID        int       `db:"id"`
    Name      string    `db:"name"`
    Email     string    `db:"email"`
    Version   int       `db:"version"` // incremented on every update
    UpdatedAt time.Time `db:"updated_at"`
}

func UpdateUserProfileOptimistic(ctx context.Context, db *sql.DB, profile UserProfile) error {
    result, err := db.ExecContext(ctx, `
        UPDATE users
        SET name = $1, email = $2, version = version + 1, updated_at = NOW()
        WHERE id = $3 AND version = $4  -- only update if version matches
    `, profile.Name, profile.Email, profile.ID, profile.Version)
    if err != nil {
        return err
    }

    rows, err := result.RowsAffected()
    if err != nil {
        return err
    }
    if rows == 0 {
        // Version mismatch — someone already updated before us
        return ErrConflict
    }
    return nil
}

// ✅ Pessimistic locking with SELECT FOR UPDATE
func UpdateUserProfilePessimistic(ctx context.Context, db *sql.DB, userID int, fn func(*UserProfile) error) error {
    tx, err := db.BeginTx(ctx, nil)
    if err != nil {
        return err
    }
    defer tx.Rollback()

    var profile UserProfile
    err = tx.QueryRowContext(ctx, `
        SELECT id, name, email, version FROM users WHERE id = $1 FOR UPDATE
    `, userID).Scan(&profile.ID, &profile.Name, &profile.Email, &profile.Version)
    if err != nil {
        return err
    }

    if err := fn(&profile); err != nil {
        return err
    }

    _, err = tx.ExecContext(ctx, `
        UPDATE users SET name = $1, email = $2, version = version + 1 WHERE id = $3
    `, profile.Name, profile.Email, profile.ID)
    if err != nil {
        return err
    }

    return tx.Commit()
}

12. Read-Your-Writes Violation (Replication Lag)

What's the Problem?

The application writes to the primary but reads from a replica that hasn't synced yet. The user performs an action, then sees data as if the action never happened — even though it was already saved.

Case Study

A social media platform. A user posts a tweet and is immediately redirected to their profile. But the tweet doesn't appear because it was read from a replica with a 2-second lag. The user thinks the post failed and posts again — duplicate tweet.

Solution in Golang

Go
// ✅ Sticky session: after writing, read from primary for a period of time
type DBRouter struct {
    primary  *sql.DB
    replicas []*sql.DB
    // map userID → deadline for reading from primary
    stickyMu sync.RWMutex
    sticky   map[int]time.Time
}

func (r *DBRouter) AfterWrite(userID int) {
    r.stickyMu.Lock()
    r.sticky[userID] = time.Now().Add(5 * time.Second)
    r.stickyMu.Unlock()
}

func (r *DBRouter) ReadDB(userID int) *sql.DB {
    r.stickyMu.RLock()
    deadline, ok := r.sticky[userID]
    r.stickyMu.RUnlock()

    if ok && time.Now().Before(deadline) {
        return r.primary // read from primary
    }
    return r.replicas[userID%len(r.replicas)] // read from replica
}

// ✅ Or use token-based consistency
func CreatePost(ctx context.Context, db *DBRouter, userID int, content string) (string, error) {
    postID, err := insertPost(ctx, db.primary, userID, content)
    if err != nil {
        return "", err
    }

    // Mark that this user needs read-your-writes for the next 5 seconds
    db.AfterWrite(userID)
    return postID, nil
}

PART 4: Schema Design Problems

13. God Table (Table with 200+ Columns)

What's the Problem?

All data from various entities is mixed into one giant table. Any query becomes slow due to the large row size. ALTER TABLE becomes a terrifying operation.

Case Study

A SaaS platform with a users table containing 240 columns — ranging from personal data, UI preferences, billing info, analytics flags, to feature flags. Even a SELECT id, name FROM users query still has to read the full 8KB row from disk.

Solution in Golang

Go
// ❌ Anti-pattern: God table
// users(id, name, email, phone, address, city, country,
//        stripe_customer_id, plan_type, billing_cycle,
//        ui_theme, ui_language, notification_prefs,
//        last_login, login_count, ...240 other columns)

// ✅ Decompose by domain
/*
users(id, name, email, created_at)              -- core identity
user_billing(user_id, stripe_id, plan, cycle)   -- billing domain
user_preferences(user_id, theme, language, notif_settings)  -- preferences
user_activity(user_id, last_login, login_count)  -- analytics
*/

// ✅ In Golang, fetch only the domain you need
type UserCore struct {
    ID    int    `db:"id"`
    Name  string `db:"name"`
    Email string `db:"email"`
}

type UserBilling struct {
    UserID   int    `db:"user_id"`
    StripeID string `db:"stripe_id"`
    Plan     string `db:"plan"`
}

func GetUserWithBilling(ctx context.Context, db *sqlx.DB, userID int) (*UserCore, *UserBilling, error) {
    var core UserCore
    var billing UserBilling

    // Two small queries > one query from a God table
    errCh := make(chan error, 2)
    go func() { errCh <- db.GetContext(ctx, &core, "SELECT id, name, email FROM users WHERE id=$1", userID) }()
    go func() { errCh <- db.GetContext(ctx, &billing, "SELECT user_id, stripe_id, plan FROM user_billing WHERE user_id=$1", userID) }()

    for i := 0; i < 2; i++ {
        if err := <-errCh; err != nil && err != sql.ErrNoRows {
            return nil, nil, err
        }
    }
    return &core, &billing, nil
}

14. EAV Anti-pattern (Entity-Attribute-Value)

What's the Problem?

A "one table for all dynamic attributes" design with the structure (entity_id, attribute_name, value). It looks flexible, but querying all attributes of a single entity requires N joins or N queries.

Case Study

A CMS with a page_attributes(page_id, key, value) table. Rendering one page with 30 attributes requires 30 rows that must be pivoted in the application. When listing 1,000 pages at once: 30,000 rows transferred.

Solution in Golang

Go
// ❌ EAV anti-pattern
// page_attributes: page_id=1, key='title', value='Hello'
// page_attributes: page_id=1, key='slug', value='hello'
// page_attributes: page_id=1, key='published', value='true'

// ✅ Solution 1: Use regular columns for known attributes
/*
CREATE TABLE pages (
    id          SERIAL PRIMARY KEY,
    title       TEXT NOT NULL,
    slug        TEXT UNIQUE NOT NULL,
    published   BOOLEAN DEFAULT false,
    created_at  TIMESTAMPTZ DEFAULT NOW()
);
*/

// ✅ Solution 2: JSONB for truly dynamic attributes
/*
CREATE TABLE pages (
    id       SERIAL PRIMARY KEY,
    title    TEXT NOT NULL,
    metadata JSONB DEFAULT '{}'  -- for dynamic attributes
);
CREATE INDEX idx_pages_metadata ON pages USING GIN(metadata);
*/

func GetPageWithMetadata(ctx context.Context, db *sql.DB, slug string) (*Page, error) {
    var page Page
    var metadataJSON []byte
    err := db.QueryRowContext(ctx, `
        SELECT id, title, slug, metadata FROM pages WHERE slug = $1
    `, slug).Scan(&page.ID, &page.Title, &page.Slug, &metadataJSON)
    if err != nil {
        return nil, err
    }
    json.Unmarshal(metadataJSON, &page.Metadata)
    return &page, nil
}

// ✅ Query JSONB with an index
func GetPagesByTag(ctx context.Context, db *sql.DB, tag string) ([]Page, error) {
    rows, err := db.QueryContext(ctx, `
        SELECT id, title, slug
        FROM pages
        WHERE metadata @> $1::jsonb  -- GIN index is used!
    `, fmt.Sprintf(`{"tags": ["%s"]}`, tag))
    defer rows.Close()
    return scanPages(rows, err)
}

15. Premature Normalization (Join Hell)

What's the Problem?

Over-normalization splits data that is frequently read together across too many tables. A simple query requires 7-10 JOINs, making it difficult for the query planner to optimize.

Case Study

An e-commerce platform with a query to display an order: JOINs to orders, order_items, products, categories, users, addresses, payment_methods, shipping_options — 8 JOINs for a single page. Query plan: 45ms. After denormalizing a few fields: 3ms.

Solution in Golang

Go
// ✅ Denormalize fields that are frequently read together
/*
-- Instead of joining to products every time, store a snapshot when the order is created
ALTER TABLE order_items ADD COLUMN product_name TEXT;
ALTER TABLE order_items ADD COLUMN product_price NUMERIC(10,2);

-- Populate on insert
INSERT INTO order_items (order_id, product_id, product_name, product_price, qty)
SELECT $1, p.id, p.name, p.price, $2
FROM products p WHERE p.id = $3;
*/

// ✅ In Golang, create a flat DTO for display needs
type OrderDisplayItem struct {
    ItemID       int     `db:"item_id"`
    ProductName  string  `db:"product_name"`  // denormalized
    ProductPrice float64 `db:"product_price"` // denormalized
    Qty          int     `db:"qty"`
    Subtotal     float64 `db:"subtotal"`
}

func GetOrderItems(ctx context.Context, db *sql.DB, orderID int) ([]OrderDisplayItem, error) {
    rows, err := db.QueryContext(ctx, `
        SELECT id as item_id, product_name, product_price, qty,
               product_price * qty AS subtotal
        FROM order_items
        WHERE order_id = $1
    `, orderID)
    // No JOINs! Data is already stored when the order was created.
    defer rows.Close()
    return scanOrderItems(rows, err)
}

PART 5: Operational Problems

16. Connection Pool Exhaustion

What's the Problem?

Forgotten rows.Close() calls or transactions that are never rolled back prevent connections from returning to the pool. The pool runs dry, and all subsequent requests block waiting for an available connection.

Solution in Golang

Go
// ✅ Proper pool configuration
func NewDB(dsn string) (*sql.DB, error) {
    db, err := sql.Open("pgx", dsn)
    if err != nil {
        return nil, err
    }
    db.SetMaxOpenConns(25)
    db.SetMaxIdleConns(10)
    db.SetConnMaxLifetime(5 * time.Minute)
    db.SetConnMaxIdleTime(2 * time.Minute)
    return db, nil
}

// ✅ Correct pattern: defer rows.Close() IMMEDIATELY after checking for errors
func GetCategories(ctx context.Context, db *sql.DB) ([]Category, error) {
    rows, err := db.QueryContext(ctx, "SELECT id, name FROM categories")
    if err != nil {
        return nil, err
    }
    defer rows.Close() // ← ALWAYS here

    var categories []Category
    for rows.Next() {
        var c Category
        if err := rows.Scan(&c.ID, &c.Name); err != nil {
            return nil, err
        }
        categories = append(categories, c)
    }
    return categories, rows.Err()
}

// ✅ Monitor pool health
func MonitorDBPool(db *sql.DB) {
    stats := db.Stats()
    if float64(stats.InUse)/float64(stats.MaxOpenConnections) > 0.8 {
        log.Warn().
            Int("in_use", stats.InUse).
            Int64("wait_count", stats.WaitCount).
            Msg("DB pool usage > 80% — consider increasing MaxOpenConns or optimizing queries")
    }
}

17. Table Bloat (Dead Tuples Accumulation)

What's the Problem?

In PostgreSQL, UPDATE and DELETE don't immediately remove data — they create dead tuples that still occupy disk space. Without proper VACUUM, a table can grow 10x larger than it should, and all queries slow down because they have to read more disk pages.

Case Study

A sessions table in a web application deleting millions of rows per day. After 6 months, a table that should be 100MB has grown to 8GB. Queries on this table take 5 seconds because sequential scans read through 8GB of data.

Solution in Golang

Go
// ✅ Detect table bloat
const bloatQuery = `
    SELECT
        tablename,
        pg_size_pretty(pg_total_relation_size(tablename::regclass)) AS total_size,
        pg_size_pretty(pg_relation_size(tablename::regclass)) AS table_size,
        n_dead_tup,
        n_live_tup,
        ROUND(100.0 * n_dead_tup / NULLIF(n_live_tup + n_dead_tup, 0), 2) AS dead_pct
    FROM pg_stat_user_tables
    WHERE n_dead_tup > 10000
    ORDER BY n_dead_tup DESC;
`

func CheckTableBloat(ctx context.Context, db *sql.DB) error {
    rows, err := db.QueryContext(ctx, bloatQuery)
    if err != nil {
        return err
    }
    defer rows.Close()

    for rows.Next() {
        var table, totalSize, tableSize string
        var deadTup, liveTup int64
        var deadPct float64
        rows.Scan(&table, &totalSize, &tableSize, &deadTup, &liveTup, &deadPct)

        if deadPct > 20 {
            log.Warn().
                Str("table", table).
                Float64("dead_pct", deadPct).
                Str("total_size", totalSize).
                Msg("Table bloat detected — consider VACUUM ANALYZE")
        }
    }
    return nil
}

// ✅ For high-delete tables, use partitioning instead of DELETE
/*
-- Partition the sessions table per week
CREATE TABLE sessions (
    id         UUID DEFAULT gen_random_uuid(),
    user_id    INT NOT NULL,
    expires_at TIMESTAMPTZ NOT NULL
) PARTITION BY RANGE (expires_at);

-- Drop old partition = instant cleanup without bloat
DROP TABLE sessions_2026_w01;
*/

18. Migration Lock (ALTER TABLE on Large Tables)

What's the Problem?

ALTER TABLE in PostgreSQL acquires an Access Exclusive Lock — blocking all reads and writes. On a table with 100 million rows, this can last minutes to hours.

Case Study

An engineering team adds a nullable column to the orders table (80 million rows) during peak traffic. The migration takes 8 minutes. During that time, all requests touching the orders table timeout. Incident P1.

Solution in Golang

Go
// ✅ Use phased migration for zero-downtime

/*
Step 1: Add nullable column WITHOUT default (instant in PostgreSQL 11+)
ALTER TABLE orders ADD COLUMN notes TEXT;

Step 2: Backfill data in batches (does not lock the table)
UPDATE orders SET notes = '' WHERE id BETWEEN 1 AND 100000 AND notes IS NULL;
UPDATE orders SET notes = '' WHERE id BETWEEN 100001 AND 200000 AND notes IS NULL;
-- etc., in small batches

Step 3: Set default and NOT NULL after all rows are populated
ALTER TABLE orders ALTER COLUMN notes SET DEFAULT '';
ALTER TABLE orders ALTER COLUMN notes SET NOT NULL;
*/

// ✅ In Golang: run backfill as a background job
func BackfillColumnInBatches(ctx context.Context, db *sql.DB, batchSize int) error {
    var minID, maxID int
    db.QueryRowContext(ctx, "SELECT MIN(id), MAX(id) FROM orders WHERE notes IS NULL").
        Scan(&minID, &maxID)

    for start := minID; start <= maxID; start += batchSize {
        end := start + batchSize - 1
        _, err := db.ExecContext(ctx, `
            UPDATE orders SET notes = ''
            WHERE id BETWEEN $1 AND $2 AND notes IS NULL
        `, start, end)
        if err != nil {
            return fmt.Errorf("backfill batch %d-%d: %w", start, end, err)
        }

        // Small pause to give the database a breather
        time.Sleep(10 * time.Millisecond)
    }
    return nil
}

19. ORM Lazy Loading Trap

What's the Problem?

ORM uses lazy loading: relationships aren't fetched until first accessed. In a loop, this produces hidden N+1 queries — invisible from the code, but happening behind the scenes.

Case Study

GORM in Go. The developer writes:

Go
var orders []Order
db.Find(&orders) // 1 query
for _, o := range orders {
    fmt.Println(o.User.Name) // ← GORM fetches users one by one!
}
// Total: 1 + N queries

Solution in Golang

Go
// ❌ Lazy loading trap with GORM
func GetOrdersWithUserBad(db *gorm.DB) ([]Order, error) {
    var orders []Order
    db.Find(&orders)
    for _, o := range orders {
        _ = o.User.Name // Triggers a query per order!
    }
    return orders, nil
}

// ✅ Use Preload (eager loading) in GORM
func GetOrdersWithUserGood(db *gorm.DB) ([]Order, error) {
    var orders []Order
    err := db.Preload("User").Preload("Items").Find(&orders).Error
    // GORM generates: SELECT * FROM orders; SELECT * FROM users WHERE id IN (...);
    return orders, err
}

// ✅ Or better yet: write a raw query with JOIN for full control
func GetOrdersWithUserRaw(ctx context.Context, db *sqlx.DB, limit int) ([]OrderWithUser, error) {
    var result []OrderWithUser
    err := db.SelectContext(ctx, &result, `
        SELECT o.id, o.total, o.status,
               u.id as user_id, u.name as user_name, u.email as user_email
        FROM orders o
        INNER JOIN users u ON u.id = o.user_id
        WHERE o.status != 'cancelled'
        ORDER BY o.created_at DESC
        LIMIT $1
    `, limit)
    return result, err
}

20. ORM Generated Query Explosion

What's the Problem?

ORMs sometimes generate queries far more complex than necessary — nested subqueries, redundant conditions, or repeated SELECTs for the same thing.

Case Study

A GORM-based application. A query for "active users with their latest order" uses GORM's method chaining and produces a query with 3 levels of nested subqueries and an execution time of 800ms. Rewritten as raw SQL: 12ms.

Solution in Golang

Go
// ❌ GORM method chaining that generates poor queries
func GetActiveUsersWithLatestOrderBad(db *gorm.DB) ([]User, error) {
    var users []User
    db.Where("active = ?", true).
        Preload("Orders", func(db *gorm.DB) *gorm.DB {
            return db.Order("created_at desc")
        }).
        Find(&users)
    return users, nil
    // Generated: suboptimal nested subquery
}

// ✅ Raw SQL for complex queries — transparent and EXPLAIN-able
const activeUsersWithLatestOrderSQL = `
    SELECT DISTINCT ON (u.id)
        u.id, u.name, u.email,
        o.id as latest_order_id,
        o.total as latest_order_total,
        o.created_at as latest_order_at
    FROM users u
    LEFT JOIN orders o ON o.user_id = u.id
    WHERE u.active = true
    ORDER BY u.id, o.created_at DESC NULLS LAST
    LIMIT $1
`

type UserWithLatestOrder struct {
    ID               int        `db:"id"`
    Name             string     `db:"name"`
    Email            string     `db:"email"`
    LatestOrderID    *int       `db:"latest_order_id"`
    LatestOrderTotal *float64   `db:"latest_order_total"`
    LatestOrderAt    *time.Time `db:"latest_order_at"`
}

func GetActiveUsersWithLatestOrderGood(ctx context.Context, db *sqlx.DB, limit int) ([]UserWithLatestOrder, error) {
    var result []UserWithLatestOrder
    err := db.SelectContext(ctx, &result, activeUsersWithLatestOrderSQL, limit)
    return result, err
}

Pre-Deployment Checklist


Closing

Database problems aren't just about slow queries. It's about understanding the entire data lifecycle — from how data is written, stored, and read, to how the system behaves under concurrency. Every problem above shares one thing in common: they're invisible in development, and only explode in production.

Your best weapons: EXPLAIN ANALYZE before deploying, pg_stat_statements in production, OpenTelemetry tracing in Golang, and code reviews that always ask — "What will this look like at 1 million rows and 10,000 concurrent users?"

Related stories

Engineering
Engineering
Integer to Roman: The Greedy Mapping That Always Shows Up in Interviews

Integer to Roman: The Greedy Mapping That Always Shows Up in Interviews

Breaking down the integer to Roman numeral conversion problem — from basic Roman numeral rules, the subtractive notation pitfalls (IV, IX, XL...), to a clean, interview-ready greedy O(1) solution. Complete with mapping table, step-by-step visualization, and why this approach is optimal given the 1-3999 constraint.
Faisal Affan
Faisal Affan
Published
Engineering
Best Time to Buy and Sell Stock: From Brute Force to One-Pass O(n)

Best Time to Buy and Sell Stock: From Brute Force to One-Pass O(n)

Master the classic coding interview problem 'Best Time to Buy and Sell Stock' with step-by-step explanation — from the naive brute force approach, recognizing patterns, to the optimal one-pass O(n) solution using sliding window / two-pointer technique. Complete with array visualization, complexity analysis, and the intuition behind every decision.
Faisal Affan
Faisal Affan
Published
Engineering
Services I've Handled — Full-Stack Engineering, Architecture & Product Strategy

Services I've Handled — Full-Stack Engineering, Architecture & Product Strategy

A comprehensive list of professional services I've delivered across multiple industries: IoT fleet management, fintech core banking integration, cyber insurance platforms, education technology, and enterprise digital transformation. Each service backed by real production systems still running today.
Faisal Affan
Faisal Affan
Published
Engineering
Portfolio Strategy by Career Level — Junior to CTO

Portfolio Strategy by Career Level — Junior to CTO

Your portfolio should reflect your seniority level, not just list every project you've touched. Junior engineers sell potential and fundamentals. Senior engineers sell depth and architecture. Staff engineers sell leverage. CTOs sell business outcomes. Here's exactly what to put in your portfolio at each level — with concrete examples and a universal format you can use today.
Faisal Affan
Faisal Affan
Published