Situation: You have events(event_id UUID PK, user_id UUID, event_type text, payload JSONB, occurred_at timestamptz) and must optimize for (a) recent per-user, (b) filtering by type + time range, (c) queries on payload keys.
Recommended schema/index choices and rationale:
- Recent events per user (fast ORDER BY occurred_at DESC, LIMIT):
- B-tree composite index: CREATE INDEX ix_events_user_occurred_desc ON events (user_id, occurred_at DESC);
- Rationale: supports WHERE user_id = ? ORDER BY occurred_at DESC LIMIT N with an index-only scan if needed columns are included. Put user_id first to allow equality lookup then ordered by time.
- Filter by event_type and time range:
- B-tree composite: CREATE INDEX ix_events_type_occurred ON events (event_type, occurred_at);
- Rationale: efficient for queries like WHERE event_type = 'X' AND occurred_at BETWEEN a AND b.
- Partial index for hot/common types or recent window:
CREATE INDEX ix_events_type_purchase_recent ON events (occurred_at DESC) WHERE event_type = 'purchase' AND occurred_at >= now() - INTERVAL '90 days';
- Rationale: smaller index tailored to high-traffic filters improves IO and cache hit; good for rolling-window queries.
- Inspect specific keys inside payload:
Choose index strategy by query shape:
- containment (@>) queries (e.g., payload @> '{"order_id":"123"}'):
CREATE INDEX ix_events_payload_gin ON events USING GIN (payload jsonb_path_ops);
- Rationale: jsonb_path_ops is compact and faster for containment, but only supports @> and not existence of arbitrary keys. If you need full text search inside values, use default jsonb_ops (slower, more general).
- existence / key lookup (payload ? 'key') or value comparisons on a specific key used often:
- Expression b-tree index: CREATE INDEX ix_events_payload_orderid ON events ((payload->>'order_id'));
- Rationale: supports WHERE payload->>'order_id' = '123' and ORDER BY on that expression; b-tree is good for equality/range on extracted scalar.
- JSON path queries: if using SQL/JSON path (jsonb_path_query), evaluate and consider functional indexes on the used expression.
Additional notes / trade-offs:
- Consider covering indexes: include frequently selected columns to enable index-only scans (Postgres INCLUDE clause).
- Partial indexes reduce maintenance cost but only match queries satisfying WHERE.
- GIN indexes are larger and slower to update (write cost). If writes are heavy, balance by indexing only high-value access patterns.
- Keep statistics up-to-date (ANALYZE), use pg_stat_statements to verify index usage, and monitor bloat (VACUUM/REINDEX as needed).
Example queries matched to indexes:
- Recent user events: SELECT * FROM events WHERE user_id = $1 ORDER BY occurred_at DESC LIMIT 50; -> ix_events_user_occurred_desc
- Type + time: SELECT * FROM events WHERE event_type='click' AND occurred_at > now() - INTERVAL '7 days'; -> ix_events_type_occurred (or partial)
- Payload key: SELECT * FROM events WHERE payload->>'order_id' = 'abc'; -> ix_events_payload_orderid (expression index) or payload @> '{"order_id":"abc"}' -> GIN
This mix balances read performance, write overhead, and storage; start with the composite B-tree indexes plus one GIN for general JSON containment and add expression/partial indexes for high-frequency queries after verifying with EXPLAIN.