Skip to main content
This hands-on lab demonstrates how a single index can transform a slow PostgreSQL query into a fast one. You’ll:
  • Populate a table with 5 million rows.
  • Run a representative query and observe poor performance.
  • Inspect the query plan to identify the bottleneck.
  • Add an appropriate index.
  • Re-run the query and observe the performance improvement.
Follow the steps below on a local PostgreSQL instance. Adjust database names and connection strings as needed.
This lesson uses PostgreSQL for examples (PostgreSQL documentation). The same concepts apply to other relational databases, though bulk-insert and EXPLAIN output may differ slightly.

At-a-glance: What you will run

1) Create the table

Create a compact schema to simulate an events log. This table uses bigserial for a primary key and stores user_id, a timestamp, and a payload.

2) Populate the table with 5 million rows

Use PostgreSQL’s generate_series() to insert rows efficiently in a single statement. This example distributes user_id values across 1..100000 to ensure repetition and realistic selectivity for WHERE filters.
Why this approach:
  • One-statement bulk inserts avoid round-trips and are fast.
  • The modulus on user_id creates repeated values useful for selective queries.
After loading, update planner statistics so EXPLAIN has accurate estimates:

3) Run the baseline query that will be slow

Run an aggregation filtered by a non-indexed column to produce a worst-case plan. Use EXPLAIN ANALYZE to capture both the planner’s decision and the actual runtime.
Expected behavior: With no index on user_id, PostgreSQL will typically do a sequential scan across all 5 million rows, resulting in high I/O and long execution time. Example conceptual output you might see:

4) Diagnose why the query is slow

Key points:
  • A sequential scan reads every row because no index exists to target matching user_id values.
  • Use EXPLAIN / EXPLAIN ANALYZE to confirm scan type and estimated row counts.
  • If the predicate is selective (matches a small fraction of rows), an index will reduce I/O dramatically.
Tools and references:

5) Create an index on the filtered column

Add an index on user_id so the planner can avoid scanning the entire table.
Notes:
  • CONCURRENTLY prevents write-locking the table during index build (recommended in production). It must run outside an explicit transaction.
  • For a local test where you can afford a quick lock, you may omit CONCURRENTLY for a faster build:
  • After index creation, run:
    PostgreSQL often updates stats during a concurrent build, but an explicit ANALYZE is safe.

6) Re-run the query and compare performance

Run the same query again with EXPLAIN ANALYZE to confirm the planner uses the index and that execution time drops significantly:
Example conceptual output showing the improvement:
Notes on results:
  • If the planner chooses an Index Only Scan, that indicates the index contains all required columns and PostgreSQL avoided accessing the heap.
  • Performance gains depend on selectivity, caching, and the storage layout of your table.

7) Wrap-up, tips, and best practices

  • Always use EXPLAIN / EXPLAIN ANALYZE to verify the planner’s decisions before and after adding indexes.
  • Indexes improve read performance but increase write latency and storage use—index only what is necessary.
  • For queries filtering on multiple columns, consider multi-column or composite indexes that match the WHERE clause.
  • On heavy write workloads, prefer CREATE INDEX CONCURRENTLY to avoid blocking writes.
  • For frequent COUNT(*) queries over large tables, consider summary tables, materialized views, or approximate-count solutions instead of repeatedly scanning millions of rows.
Helpful links and references:
If you want to repeat this experiment from a clean state, drop the index and truncate the table between runs:

Watch Video

Practice Lab