👋 Hey friends,

Here’s what I’ve got in store for you this week:

  • Another snippet from The Backend Lowdown

  • Recent post recap: Write Contention

  • Next post preview: Fixing the hot-row upsert

Let’s get into it 👇

Every newsletter will include a snippet from my book in progress, The Backend Lowdown, available for $5 right now on Gumroad!

Get The Backend Lowdown →

Chapter 2: Indexing, Query Planning, and Performance

What is an Index?

An index is a data structure the database uses to find rows faster - like a shortcut to avoid scanning every row in a table. If you've ever used a book index to jump straight to a topic, you already get the idea.

Without an index, a query like:

SELECT * FROM users WHERE email = '[email protected]';

requires the database to scan every row in the users table to see if email matches. This is called a sequential scan, and it works - but it's slow at scale.

With an index on email, the database can jump directly to the rows it needs, skipping everything else. That can turn a query that takes seconds into one that runs in milliseconds.

The Hidden Cost of Indexes

Indexes can dramatically improve read performance - but they're not free. In fact, they're one of the easiest ways to accidentally hurt your system's performance while thinking you're helping. Here's how that happens, and how to avoid it.

Common Ways Indexes Hurt

  • They slow down writes.
    Every time you insert, update, or delete a row, the DB has to update every relevant index. The more indexes you have, the more work each write operation does.

  • They take up disk space.
    Especially on wide tables or large datasets, indexes can significantly bloat your storage - and backups.

  • They can mislead the query planner.
    Too many indexes (especially overlapping or misaligned ones) can confuse the planner and lead to suboptimal execution paths.

  • They slow down bulk operations.
    Large INSERTs, batch updates, or data migrations are often much slower with indexes in place. This is why it's common to temporarily drop indexes before importing millions of rows.

How to Know What to Index

  • Start from the query
    Don't guess - use EXPLAIN ANALYZE on your slowest queries to see what the database actually needs.

  • Only index columns used in filters or joins
    Indexes help most on columns used in WHERE, JOIN, and sometimes ORDER BY.

  • Watch out for low-cardinality fields
    Indexing columns like archived or status rarely helps on their own - unless paired with another condition.

  • Keep multicolumn indexes lean and purposeful
    Column order matters. Indexes on (user_id, created_at) won't help if your query only filters by created_at.

  • Skip indexes during large imports
    For big INSERT jobs, drop non-essential indexes first and recreate them afterward to speed things up.

Recent Post Recap

In case you missed it, this week’s post shows how a single line of SQL can turn a healthy Postgres database into a single-file line and how to spot it quickly with the DB Load / Average Active Sessions (AAS) chart (see the previous post which introduces the chart).

The setup: low-cardinality upserts = collisions

The problematic upsert

The scenario is an INSERT ... ON CONFLICT DO UPDATE against a low-cardinality table (meaning there are only a few possible keys/rows). Under load, lots of requests “collide” on the same row(s).

The subtle footgun is this pattern:

  • ON CONFLICT ... DO UPDATE

  • and you update something every time (like updated_at)

  • so every conflict becomes a real update, not a no-op

Result: you’ve accidentally created a hot row.

Low-cardinality UPSERT causing write contention

Why it hurts: row locks → queueing

When many requests try to update the same row, Postgres has to coordinate access using row-level locks. Only one writer can proceed at a time, so everyone else waits and the system feels “slow” not because it’s out of CPU, but because it’s lined up behind a lock.

Row-level lock coordination

The chart signal: AAS spikes, locks dominate

This shows up cleanly in the DB Load / AAS chart:

  • AAS spikes

  • the stack shifts away from CPU

  • and you see Lock waits grow (commonly transactionid and tuple)

That pattern is a strong hint you’re jammed on writes, often due to a hot-row / low-cardinality upsert like this.

In the next post, I’m going to walk through a few practical ways to fix the write contention we saw and the tradeoffs each option comes with.

We’ll start by converting the upsert from ON CONFLICT DO UPDATE to ON CONFLICT DO NOTHING. That change alone can remove a huge chunk of the contention because conflicts stop turning into real updates (and you stop forcing Postgres to serialize writers on the same row).

But it’s not a silver bullet.

Under extreme concurrency, even DO NOTHING can still run into bottlenecks and surprising edge cases. I’ll break down what can still go wrong, why it happens, and a few mitigation techniques you can use to keep the behavior you want without reintroducing a “single-file line” in the database.

Stay tuned.

That’s a wrap for this week. If something here made your day smoother, feel free to reply and tell me about it. And if you think a friend or teammate would enjoy this too, I’d be grateful if you shared it with them.

Until next time,
Steven

Keep Reading