Postgres Indexing Through Rails Migrations
Objective
An index in a Rails app is not a switch you flip when a page feels slow — it is a
line in a migration that Postgres has to maintain on every INSERT,
UPDATE, and DELETE against that table, forever. add_index is a small API
with a surprising amount of meaning packed into its options (order:, where:,
unique:, an expression instead of a column name, an array instead of a single
symbol), and Rails is deliberately unopinionated about which indexes you need:
it will happily let you ship a belongs_to whose foreign key has no index at
all. Knowing which indexes are effectively mandatory in a Rails schema, which
ones a composite index actually buys you, and how to confirm from a Rails console
that Postgres is really using the index you wrote is the difference between
tuning and guessing.
Use Cases
- Reviewing a migration that adds a
belongs_to/referencesassociation and checking whether the foreign key actually got an index — Rails does not add one unless you ask. - Deciding between two
add_indexcalls on single columns versus one compositeadd_index :table, [:a, :b], given that Postgres can combine single-column indexes on its own via a bitmap index scan. - Making a key-based (Russian doll) cache expiration query fast, where the
hot query is
MAX(updated_at)over a collection rather than a lookup by id. - Confirming in a Rails console — with
.explainorEXPLAIN ANALYZE— that a slow scope is hitting the index you added, instead of assuming it is because the migration ran. - Diagnosing a table whose disk usage keeps growing even though row counts are
flat, and deciding whether it needs autovacuum tuning or a maintenance-window
VACUUM FULL.
Deep Dive
Composite indexes: column order is the whole game
A composite index only helps when the query's column order lines up with the index's column order.
plaintextclass AddIndexToOrders < ActiveRecord::Migration[7.1] def change add_index :orders, [:customer_id, :status] end end
That index serves Order.where(customer_id: 1, status: "open") and it also
serves Order.where(customer_id: 1) on its own, because customer_id is the
leading column. It does not help Order.where(status: "open") — a query
that skips the leading column cannot use the index as a search structure. This is
often called the leftmost-prefix rule: an index on [:a, :b, :c] covers a,
a, b, and a, b, c, but not b alone or b, c.
The tempting reaction is to add composite indexes for every column combination the app queries. Resist it. Postgres can already combine two separate single-column indexes at query time using a bitmap index scan — it builds a bitmap of matching rows from each index, ANDs them together, and then visits the heap once:
plaintextadd_index :orders, :customer_id add_index :orders, :status
Those two indexes handle customer_id alone, status alone, and (via bitmap
AND) both together. A composite index is faster than a bitmap AND for its one
specific shape, but it is an ongoing write cost — every write to orders has to
maintain it — and it is dead weight for every query that does not lead with
customer_id. Reach for the composite index when you have measured that one
specific query shape and it matters; not by default.
The exception where the composite really is the right call is when the pair is semantically inseparable — a polymorphic association being the canonical example (below).
The indexes a Rails app almost always needs
Foreign keys. Rails does not index a belongs_to foreign key for you. A
bare add_column :comments, :post_id, :bigint gives you an unindexed column,
and then every post.comments lookup is a sequential scan. Both of these do add
the index:
plaintextclass AddPostToComments < ActiveRecord::Migration[7.1] def change # add_reference adds the index by default (index: true is the default) add_reference :comments, :post, foreign_key: true # or, on an existing column: add_index :comments, :post_id end end
Note that foreign_key: true (the database-level referential constraint) and
the index are two different things — the constraint does not create the index,
and an index does not enforce referential integrity. You generally want both.
Polymorphic columns. A polymorphic association is queried by _type and
_id together, always, so this is the case where the composite index is
clearly right:
plaintextclass IndexCommentablesOnComments < ActiveRecord::Migration[7.1] def change add_index :comments, [:commentable_type, :commentable_id] end end
add_reference :comments, :commentable, polymorphic: true creates exactly this
composite index for you. Writing the two columns as separate single-column
indexes here would be strictly worse: commentable_type alone has terrible
selectivity (a handful of distinct class names across the whole table).
updated_at / created_at when used for cache keys. Key-based cache
expiration (see the russian-doll-caching sibling concept) builds a cache key
from the collection's maximum updated_at, so the query that runs on every
render is not a lookup — it is SELECT MAX(updated_at) FROM posts. Postgres can
answer that from an index by walking to one end of it, but only if the index's
sort order matches, and NULLS LAST matters because NULL sorts as the largest
value by default in a descending index:
plaintextclass AddUpdatedAtIndexToPosts < ActiveRecord::Migration[7.1] def change add_index :posts, :updated_at, order: { updated_at: "DESC NULLS LAST" } end end
The same index also serves the very common Post.order(updated_at: :desc).limit(20)
feed query, which is usually the second reason you want it.
Partial, expression, and unique indexes
A partial index indexes only the rows matching a predicate. It is the right
tool when the value distribution is skewed — if 99% of your rows are
billed = true and every query you care about is looking for the 1% that are
not, indexing all of them is wasted space and wasted write time:
plaintextclass AddUnbilledIndexToCustomers < ActiveRecord::Migration[7.1] def change add_index :customers, :billed, where: "billed = false" end end
Postgres will only use a partial index for a query whose WHERE clause it can
prove is covered by the index predicate, so Customer.where(billed: false) uses
it and Customer.where(billed: true) does not — which is the point.
An expression index indexes the result of a function rather than a raw column, which is what makes case-insensitive lookups fast:
plaintextclass AddLowerEmailIndexToUsers < ActiveRecord::Migration[7.1] def change add_index :users, "lower(email)", name: "index_users_on_lower_email" end end
The query has to use the same expression for the index to apply —
User.where("lower(email) = ?", email.downcase) hits it, while
User.where(email: email) does not. Passing an explicit name: is worth doing
here because Rails cannot derive a clean index name from an expression, and the
generated one is both awkward and easy to collide with.
Finally, prefer unique: true whenever the data genuinely is unique. A unique
index is both faster (Postgres knows it can stop at the first match) and a real
integrity constraint at the database level, which a Rails validates_uniqueness_of
alone is not — model validations lose races under concurrency:
plaintextadd_index :users, :email, unique: true
Confirming the index is used: EXPLAIN ANALYZE from Rails
Adding an index is a hypothesis; EXPLAIN ANALYZE is the test. From a Rails
console, the cheapest path is .explain on any relation:
plaintextOrder.where(customer_id: 1, status: "open").explain # => EXPLAIN for: SELECT "orders".* FROM "orders" WHERE ... # Index Scan using index_orders_on_customer_id_and_status on orders ...
In Rails 7+, .explain takes options, so you can get real execution numbers and
buffer statistics without leaving the console:
plaintextOrder.where(customer_id: 1).explain(:analyze, :buffers, :verbose)
For anything more exotic, or on older versions, drop to the connection:
plaintextputs ActiveRecord::Base.connection.execute( "EXPLAIN (ANALYZE, BUFFERS, VERBOSE) SELECT * FROM orders WHERE customer_id = 1" ).values.join("\n")
Reading the output, two things matter most. First, compare the estimated cost
against actual time — if the planner estimated 50 rows and actually got
500,000, the statistics are stale (the planner is choosing badly because it has
bad information, not because the index is wrong) and the table may need an
ANALYZE. Second, look at the node type: a Seq Scan where you expected an
Index Scan is the classic symptom that your index does not apply — wrong
column order in a composite, a query expression that does not match an
expression index, or a predicate outside a partial index's where:. BUFFERS
adds shared hit= / read= counters, which tell you whether the pages came from
Postgres' buffer cache or from disk — a plan that looks fine on a warm cache and
terrible in production is usually visible right there.
Note that ANALYZE actually executes the query, so wrap it in a transaction you
roll back if the statement writes.
Dead tuples, autovacuum, and VACUUM FULL
Postgres uses MVCC: an UPDATE does not overwrite a row, it writes a new row
version and marks the old one dead, and a DELETE just marks the row dead. Those
dead tuples still occupy pages, so a heavily-updated table bloats even when
its logical row count is constant — and bloated tables mean more pages to read
for the same result, including through indexes, which bloat alongside them.
autovacuum is the background process that reclaims dead tuples for reuse, and
it should always be on. It is enabled by default in Postgres, and managed
providers (Heroku Postgres among them) ship it enabled — the failure mode is
usually someone disabling it to "reduce load," which trades a small continuous
cost for an eventual large one.
The important caveat: ordinary VACUUM marks space reusable by that same table
but does not return it to the operating system. Only VACUUM FULL actually
shrinks the files on disk — and it does so by rewriting the entire table while
holding an ACCESS EXCLUSIVE lock, which blocks reads and writes for the
duration. That makes it a maintenance-window operation on a production table,
never something to fire off casually from a console because a disk graph looked
alarming.
Trade-offs
- Every index is a permanent write tax. The read speedup is visible in a
benchmark; the cost is spread invisibly across every
INSERTandUPDATEon that table plus the extra disk and cache pressure. Indexes that no query uses are pure loss — and they accumulate quietly, because migrations add indexes far more often than they drop them. Auditingpg_stat_user_indexesfor never-scanned indexes is worth doing periodically. - The composite-vs-two-single-column choice is not "composite is better."
A composite index wins decisively for its exact query shape and is useless for
queries that do not lead with its first column; two single-column indexes are
more flexible and let Postgres bitmap-AND them, at the cost of being slower for
that one shape. Choosing composite before you have a measured query is
optimizing a query you have not written yet.plaintext
add_index :orders, [:customer_id, :status] # great for (customer_id, status) and (customer_id) # useless for (status) - Partial and expression indexes are brittle in a specific way: they only
apply when the query text matches what the index describes. An expression index
on
lower(email)silently stops helping the moment someone rewrites the scope towhere(email: ...), and nothing fails — the query just gets slow. Both of these deserve a comment in the model next to the scope they exist for. unique: trueis a constraint, not just an optimization. It will reject writes, which means adding it to an existing table can fail on production data that already contains duplicates. That is usually the correct outcome, but it means the migration needs a dedup step first, and it turns a "performance" change into a data-integrity change with a real rollout risk.- A
DESC NULLS LASTindex onupdated_atonly pays off if the cache strategy it exists for is actually in place. If you are not doing key-based cache expiration and not renderingupdated_at-ordered feeds, it is an index on the single most frequently written column in the table — the worst possible write cost for no read benefit. VACUUM FULLfixes bloat but costs availability. The exclusive lock means the table is unavailable for the rewrite, which on a large table can be many minutes. When downtime is unacceptable, the alternatives (pg_repack, or rebuilding indexes concurrently) trade operational complexity for that availability — there is no version of this that is both free and instant.