Skip to content

Build1 publisher3 min readPublished

Your ORM never puts a WHERE clause in an index, and that is where the seq scans live

A dev.to walkthrough lays out four hand-written PostgreSQL index patterns that have existed since version 7.2. The patterns hold up. The arithmetic in the write-up does not.

The Engineer · Build desk

Drafted by a language model from the sources cited here and checked against its claim ledger before publication. How we use AISend a correction

What happened

  • The article states that index lists in struggling PostgreSQL deployments are almost always full-column indexes generated by ActiveRecord, SQLAlchemy or Hibernate, covering every row including the 97% that queries never touch.
  • PostgreSQL has supported partial and expression indexes since version 7.2; the tutorial recommends being on a supported version, PostgreSQL 12 or later.
  • An index on deleted_at or status across a 50M-row table is often worse than no index at all, because the planner may choose it, read a massive index and still return millions of rows to filter.
  • The naive ORM index CREATE INDEX idx_users_email ON users(email) indexes all 50M rows of the users table.
  • The partial index CREATE INDEX idx_users_email_active ON users(email) WHERE deleted_at IS NULL indexes only about 1M active users.

Compiled by The EngineerSomething wrong?How this is made

Why it matters

A tutorial published on dev.to makes a narrow, useful argument: the index lists in struggling PostgreSQL deployments are usually full-column indexes generated by ActiveRecord, SQLAlchemy or Hibernate, covering every row including the 97 percent that queries never touch [s1c1]. The author's point is that the remedy is ordinary DDL, and PostgreSQL has supported it since version 7.2 [s1c2], which makes this a discipline problem rather than a capability problem.

The claim with teeth is that a full-column index on `deleted_at` or `status` across a 50M-row table can be worse than no index, because the planner may read a large index and still hand back millions of rows to filter [s1c3]. The soft-delete fix is one clause: the naive index covers all 50M rows in `users`, while `CREATE INDEX ... ON users(email) WHERE deleted_at IS NULL` covers roughly 1M active users [s1c4][s1c5], about 2 percent of the table [1].

Then the numbers. The "before" plan is a sequential scan at 2840.112 ms that removed 49,020,000 rows by filter [s1c6]; the "after" plan is an index scan at 0.091 ms returning one row [s1c7]. That is 50,000,000 rows examined [2] and a ratio near 31,000x [3]. It is also not the same query twice: the first plan reports 980,000 rows matching a filter that includes an equality predicate on `email`, and the second reports one row for the same predicate [4]. Treat the plans as illustrations of shape, not measurements.

The index size figure has the same problem in the other direction. The article reports 2.1 GB dropping to 42 MB and calls that "eighty percent smaller" [s1c8], while its own summary says 90 percent [s1c9]. The real reduction is about 98 percent [5]. The win is larger than advertised, which is the forgivable direction, but a write-up that cannot divide is not a write-up whose `EXPLAIN` output you copy without rerunning.

The patterns themselves are sound and the author is honest about the weakest one. The multi-tenant example, an index on `orders(created_at DESC)` restricted to `tenant_id = 42 AND status = 'pending'`, only works for a small number of high-volume tenants known at schema design time, and is explicitly not a general multi-tenancy strategy [s1c10]. It is a hot-partition tool with a maintenance cost every time the tenant list changes.

Expression indexes carry the sharper operational trap. `WHERE LOWER(email) = LOWER($1)` will never use a plain btree on `email` [s1c11]; you need an index on `LOWER(email)` [s1c12], and the query must spell the expression exactly, because the planner matches the expression rather than the column [s1c13]. Any call site that lowercases differently loses the index silently. Same mechanism for JSONB: without an index on `(payload->>'user_id')`, every JSONB predicate is a sequential scan carrying per-row extraction cost [s1c14], and the article puts that query at 3100 ms falling to 1.1 ms [s1c15], roughly 2,800x [6].

One step is easy to skip: run `ANALYZE` manually after creating a partial or expression index, before autovacuum gets there, because a fresh expression index with no statistics in `pg_statistic` forces the planner to guess, and the guess is often badly wrong [s1c16][s1c17].

What to watch is your write path. Every index adds cost to `INSERT`, `UPDATE` and `DELETE`, and that compounds on high-write tables such as event streams [s1c18], which are exactly the tables where the JSONB expression index looks most attractive. Build the index on a copy, run `ANALYZE`, then compare `EXPLAIN ANALYZE` against the query your application actually emits rather than the one in the tutorial.

Loading claim ledger
Loading source directory links
Loading share composer
Loading topic controls
Loading related stories