How to Add an Index to Speed Up a Slow Postgres Query
When a Postgres query is slow and the table is not huge, a missing index is almost always the cause. EXPLAIN ANALYZE shows you the seq scan in seconds, and a single CREATE INDEX statement fixes it - no schema change, no downtime.
Why a 10,000-row table can be slow
A table with 10,000 rows is tiny by database standards, but Postgres still reads every single row when there is no index on the column you are filtering by. That is a sequential scan - the database cannot skip to the matching rows, so it reads them all before discarding the ones that don't match.
A search query like this will trigger a seq scan if name has no index:
SELECT * FROM products WHERE LOWER(name) LIKE 'widget%';
At 10,000 rows that costs milliseconds. At 1 million rows it costs seconds. Index it now, before the table grows.
Step 1: confirm the seq scan with EXPLAIN ANALYZE
Connect to Postgres and prefix the slow query with EXPLAIN ANALYZE:
EXPLAIN ANALYZE
SELECT * FROM products WHERE LOWER(name) LIKE 'widget%';
Look for Seq Scan on products in the plan output. The rows= and actual time=
values show how many rows were read and how long it took. A seq scan with a high
row count is the signal to add an index.
Step 2: add the right index
For a LOWER(name) LIKE 'prefix%' pattern on Postgres, a functional btree index
with text_pattern_ops is the correct choice - it covers prefix matching after case
folding:
CREATE INDEX idx_products_name_lower
ON products (LOWER(name) text_pattern_ops);
ANALYZE products; -- update planner statistics immediately
text_pattern_ops tells Postgres that the index should support LIKE/ILIKE
prefix patterns, not just equality and range comparisons. Without it, the planner
may not choose the index for LIKE 'widget%'.
Step 3: verify the plan changed
Run EXPLAIN ANALYZE again on the same query:
EXPLAIN ANALYZE
SELECT * FROM products WHERE LOWER(name) LIKE 'widget%';
You should now see Index Scan using idx_products_name_lower instead of Seq Scan.
Query time drops from seconds to single-digit milliseconds.
When to use a trigram index instead
If you need substring matching (LIKE '%widget%' - the pattern does not anchor at
the start), btree cannot help. Use a GIN trigram index instead:
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX idx_products_name_trgm
ON products USING GIN (name gin_trgm_ops);
Trigram indexes are larger and slower to build, but they handle arbitrary substrings. For prefix-only search, the functional btree approach above is faster and smaller.
Production notes
- Use
CREATE INDEX CONCURRENTLYon live tables - it builds the index without holding a write lock, so your app keeps running. pg_stat_statementssurfaces the slowest queries by total time across all executions - pair it withEXPLAIN ANALYZEto find the next target.- Indexes add write overhead and storage. Index hot read paths; skip columns that are rarely filtered.
Want to try it hands-on? HeyDevJob gives you this exact setup in a live cloud workspace in your browser - edit it, run it, and see it work. Free, nothing to install.
Try it in a workspace →What you'll practice
- Running EXPLAIN ANALYZE and reading the seq scan vs index scan output
- Adding a functional btree index with text_pattern_ops for LOWER() LIKE queries
- Verifying the query plan changed after the index is created
FAQ
How do I find out if Postgres is doing a sequential scan?
Run EXPLAIN ANALYZE in front of your query. Look for 'Seq Scan' in the output - it means Postgres is reading every row. 'Index Scan' or 'Bitmap Index Scan' means an index is being used.
Why does my Postgres LIKE query ignore the index?
A standard btree index on a text column does not support LIKE patterns unless you use text_pattern_ops (for prefix patterns) or pg_trgm + a GIN index (for substring patterns). Create the index with the right operator class.
How do I add an index without locking my production table?
Use CREATE INDEX CONCURRENTLY - Postgres builds the index in the background without taking a write lock, so inserts and updates continue. It takes longer to build but is safe on live tables.
Keep learning
Learn it by doing. Open this in a live cloud workspace, make the change yourself, and keep a record of the work you can share.
Open the workspace →