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)