Practical optimization techniques for the queries in this library. Covers indexing strategy, common pitfalls, materialization patterns, and query plan reading.
-- Core indexes for cohort queries
CREATE INDEX idx_users_signup_date ON users(signup_date);
CREATE INDEX idx_events_user_ts ON events(user_id, event_timestamp);
CREATE INDEX idx_events_name_user ON events(event_name, user_id, event_timestamp);Why: Cohort queries always filter by signup_date and then join to events by user_id + time range. The compound index on events prevents a full table scan on the fact table.
-- Funnel queries need fast lookup by event_name within time windows
CREATE INDEX idx_events_name_ts_user ON events(event_name, event_timestamp, user_id);
CREATE INDEX idx_events_session_ts ON events(session_id, event_timestamp);Why: Funnel queries filter by event_name first, then by time window. Covering the user_id in the index avoids table lookups.
-- Retention checks "was user X active on day Y?"
CREATE INDEX idx_events_user_date ON events(user_id, DATE(event_timestamp));
-- Postgres: use expression index
CREATE INDEX idx_events_user_event_date ON events(user_id, (event_timestamp::date));CREATE INDEX idx_orders_user_date_status ON orders(user_id, order_date, status);
CREATE INDEX idx_orders_date_status ON orders(order_date, status) INCLUDE (total_amount, user_id);
CREATE INDEX idx_subs_status_period ON subscriptions(status, current_period_start, current_period_end);CREATE INDEX idx_tickets_created_priority ON support_tickets(created_at, priority);
CREATE INDEX idx_tickets_status_priority ON support_tickets(status, priority, created_at);
CREATE INDEX idx_tickets_assigned ON support_tickets(assigned_to, resolved_at);Slow:
SELECT * FROM users u
WHERE EXISTS (
SELECT 1 FROM events e
WHERE e.user_id = u.user_id
AND e.event_timestamp > u.signup_date + INTERVAL '30 days'
);Fast: Rewrite as JOIN
SELECT DISTINCT u.*
FROM users u
INNER JOIN events e ON u.user_id = e.user_id
WHERE e.event_timestamp > u.signup_date + INTERVAL '30 days';Slow: (index on event_timestamp is useless here)
WHERE DATE(event_timestamp) = '2024-03-15'Fast: (uses the index)
WHERE event_timestamp >= '2024-03-15'
AND event_timestamp < '2024-03-16'Slow:
SELECT DISTINCT user_id FROM events WHERE event_name = 'purchase_completed'Fast: (if you only need existence)
SELECT user_id FROM events WHERE event_name = 'purchase_completed' GROUP BY user_idOr better, use EXISTS in the parent query.
Problem: Without PARTITION BY, window functions scan the full result set.
Solution: Always limit the dataset with WHERE before applying windows:
-- Filter first, window second
WITH filtered AS (
SELECT * FROM events WHERE event_timestamp > CURRENT_DATE - 30
)
SELECT *, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY event_timestamp)
FROM filtered;Problem: FULL OUTER JOINs prevent index usage and force hash joins.
Solution: Rewrite as UNION of LEFT JOINs when possible:
SELECT a.*, b.* FROM table_a a LEFT JOIN table_b b ON ...
UNION ALL
SELECT a.*, b.* FROM table_b b LEFT JOIN table_a a ON ... WHERE a.id IS NULLFor expensive aggregations that run frequently:
-- Create materialized view for daily metrics
CREATE MATERIALIZED VIEW mv_daily_metrics AS
SELECT
DATE(event_timestamp) AS metric_date,
COUNT(DISTINCT user_id) AS dau,
COUNT(DISTINCT session_id) AS sessions,
COUNT(*) AS total_events
FROM events
GROUP BY DATE(event_timestamp);
-- Refresh on schedule (e.g., via cron)
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_daily_metrics;Best candidates for materialization:
| Plan Node | What It Means | When to Worry |
|---|---|---|
| Seq Scan | Full table scan | On tables > 10K rows with a WHERE clause |
| Index Scan | Using an index efficiently | Normal — this is good |
| Index Only Scan | Answered entirely from index | Best case scenario |
| Bitmap Index Scan | Multiple index conditions combined | Normal for OR conditions |
| Nested Loop | Row-by-row join | Slow if outer side is large (>1000 rows) |
| Hash Join | Build hash table, probe it | Normal for equi-joins |
| Merge Join | Both sides pre-sorted | Efficient for pre-sorted data |
| Sort | Explicit sort operation | Check if an index could eliminate it |
| HashAggregate | GROUP BY using hash table | Normal |
| WindowAgg | Window function computation | Normal |
EXPLAIN output: (cost=0.43..8.45 rows=1 width=36)
^ ^ ^ ^
| | | Bytes per row
| | Estimated row count
| Total cost (higher = slower)
Startup cost
Rules of thumb:
ANALYZE table_name)Always use EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) for real performance data:
EXPLAIN (ANALYZE, BUFFERS)
SELECT ... your query ...;This shows actual execution time, not just estimates.
pg_stat_statements to find slow queries automaticallyCREATE INDEX ... WHERE status = 'active'work_mem setting affects sort/hash performancePARTITION BY DATE(event_timestamp))SELECT * — column-oriented storage charges per column scannedAPPROX_COUNT_DISTINCT instead of COUNT(DISTINCT) for estimatesALTER SESSION SET USE_CACHED_RESULT = TRUE)Get the full Advanced SQL Query Library and unlock everything.
Get the complete guide with every chapter unlocked, including code samples, diagrams, and best practices.
Access all interactive tools with complete data, all workload profiles, and the full scenario library.
Downloadable source code, configuration files, and working examples from every chapter.
Free updates for life. Every new chapter, tool, and improvement included.