REM Platforms

Subscribe to receive notifications of new posts

​
We will never share your email address.

REM Platforms

Blog

Latest postsAll topicsGeneralEngineeringSecurityAIRSS feed

Company

CareersAboutContact

Resources

Security

Legal

Privacy policyTerms of useRefundsShipping and deliveryAcceptable use

© 2026 REM Platforms Private Limited. All rights reserved.

Blog

Engineering
August 17, 2026

Postgres indexes we wish we had added sooner

Ada Lovelace

Ada Lovelace

3 minute read

Six index changes that between them removed most of our slow-query log, and the reasoning behind each one.

Discuss online

Postgres indexes we wish we had added sooner

Our slow-query log had become background noise. Every morning it held a few hundred entries, every morning somebody skimmed it, and every morning we all agreed that we should really look into that. This is what happened when we finally did.

The short version: six index changes removed 94% of the entries. None of them were clever. All of them had been visible in pg_stat_statements for the better part of a year. What follows is the reasoning behind each one, because the reasoning generalises even when the specific indexes do not.

Start with the data, not the hunches

Everyone on the team had a theory. The theories were confident, mutually contradictory, and mostly wrong. So we started with the only thing that settles an argument about performance, which is a measurement.

SELECT
    calls,
    round(total_exec_time::numeric, 1) AS total_ms,
    round(mean_exec_time::numeric, 2)  AS mean_ms,
    round(100 * total_exec_time / sum(total_exec_time) OVER (), 1) AS pct,
    query
FROM pg_stat_statements
WHERE query NOT LIKE '%pg_stat_statements%'
ORDER BY total_exec_time DESC
LIMIT 20;

The result was the usual shape: a very short head and a very long tail. Two queries accounted for just over half of all execution time. Neither was slow in isolation. Both ran constantly.

Query time distribution before and after
Total execution time by query, before the changes. Two queries, half the budget.

A query that takes 40ms and runs ten thousand times an hour costs more than one that takes four seconds and runs twice a day. Optimise the bill, not the outlier.

The six changes

1. A partial index for the rows we actually read

Our posts table had eleven million rows. Roughly four percent were in a state anyone ever queried. The index covered all eleven million.

-- Before: 11M rows indexed, ~380MB
CREATE INDEX idx_post_published ON blog_post (published_at DESC);

-- After: ~440K rows indexed, ~14MB
CREATE INDEX CONCURRENTLY idx_post_live
    ON blog_post (published_at DESC, id DESC)
    WHERE status = 'published' AND published_at IS NOT NULL;

A 27x smaller index that answers the same question. It stays in cache, and the planner stopped falling back to a sequential scan when the statistics drifted.

2. A composite index that matches the real sort order

Our feed pages on (published_at, id) to keep pagination stable when two posts share a timestamp. The index was on published_at alone, so every page did a sort.

Change

Rows scanned

Mean time

Before

11,000,000

412 ms

Partial index

440,000

38 ms

Composite, matching sort

15

0.8 ms

3. Dropping what nothing used

Two indexes had not been read since 2024. They were still being written to on every insert and update.

SELECT relname, indexrelname, idx_scan, pg_size_pretty(pg_relation_size(indexrelid))
FROM pg_stat_user_indexes
WHERE idx_scan = 0
ORDER BY pg_relation_size(indexrelid) DESC;

Check the number against a window that includes your quarterly jobs before dropping anything, and drop concurrently.

4. An expression index for a case-insensitive lookup

-- The query the ORM generates
SELECT * FROM accounts_user WHERE lower(email) = lower($1);

CREATE INDEX CONCURRENTLY idx_user_email_lower ON accounts_user (lower(email));

5. Covering the count query

Pagination counts are a classic: cheap to write, expensive to run. We replaced exact counts above ten thousand with an estimate from the planner, which nobody has noticed and which costs nothing.

SELECT reltuples::bigint AS estimate
FROM pg_class
WHERE relname = 'blog_post';

6. Raising the statistics target on a skewed column

One column held 200 distinct values with a very uneven distribution. The default sample was too small to represent it, so the planner mis-estimated and picked a nested loop over a hash join.

ALTER TABLE blog_post ALTER COLUMN status SET STATISTICS 500;
ANALYZE blog_post;

What we would tell ourselves a year ago

  1. Turn on pg_stat_statements on day one. It costs almost nothing and you cannot recover the history later.

  2. Sort by total time, never by mean time. The expensive query is rarely the slow one.

  3. Always build indexes CONCURRENTLY in production, and always check pg_stat_user_indexes before adding another.

  4. An index that is not used is not free: it is paid for on every write, forever.

The whole exercise took two afternoons. The slow-query log now holds single-digit entries on a normal day, which means somebody actually reads it.

Related topics

Engineering

Follow on social media

REM Platforms

On this page

Start with the data, not the hunchesThe six changes1. A partial index for the rows we actually read2. A composite index that matches the real sort order3. Dropping what nothing used4. An expression index for a case-insensitive lookup5. Covering the count query6. Raising the statistics target on a skewed columnWhat we would tell ourselves a year ago
August 31, 2026
How we cut our build times by 63%
Grace Hopper
Ada Lovelace

Ada Lovelace, Grace Hopper

August 28, 2026
A practical threat model for internal tools
Alan Turing

Alan Turing

August 10, 2026
What one year of incident reviews taught us
Grace Hopper
Alan Turing

Alan Turing, Grace Hopper