- 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.
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 usesbigserial for a primary key and stores user_id, a timestamp, and a payload.
2) Populate the table with 5 million rows
Use PostgreSQL’sgenerate_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.
- One-statement bulk inserts avoid round-trips and are fast.
- The modulus on
user_idcreates repeated values useful for selective queries.
3) Run the baseline query that will be slow
Run an aggregation filtered by a non-indexed column to produce a worst-case plan. UseEXPLAIN ANALYZE to capture both the planner’s decision and the actual runtime.
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_idvalues. - Use
EXPLAIN/EXPLAIN ANALYZEto 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.
EXPLAIN/EXPLAIN ANALYZE— PostgreSQL EXPLAIN documentationANALYZE— PostgreSQL ANALYZE documentation
5) Create an index on the filtered column
Add an index onuser_id so the planner can avoid scanning the entire table.
CONCURRENTLYprevents 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
CONCURRENTLYfor a faster build: - After index creation, run:
PostgreSQL often updates stats during a concurrent build, but an explicit
ANALYZEis safe.
6) Re-run the query and compare performance
Run the same query again withEXPLAIN ANALYZE to confirm the planner uses the index and that execution time drops significantly:
- 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 ANALYZEto 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 CONCURRENTLYto 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.
- PostgreSQL Documentation — CREATE INDEX
- PostgreSQL Documentation — EXPLAIN
- PostgreSQL Documentation — ANALYZE
If you want to repeat this experiment from a clean state, drop the index and truncate the table between runs: