Database indexing — B-trees, composite order, partial indexes
Index design as query design, with every index you actually created as evidence.
Concept → your projects
An index trades write amplification for read acceleration. The mental model: a B-tree (balanced, ~O(log_f N) depth — fanout ~100s, so 3–4 levels for a billion rows) whose sorted structure serves equality, range, and prefix scans, plus ORDER BY for free.
Rules that matter in interviews and in your code:
- Composite index column order = equality columns first, then range/sort.
(user_id, status)servesWHERE user_id=? AND status=?andWHERE user_id=?— but notWHERE status=?alone. Your zoth indexes prove you know this:idx_access_requests_user_status,idx_access_requests_resource_status— both queries are "show my pending/active requests" (user equality + status filter) and "show this resource's active grants" (resource + status). - A partial index indexes only matching rows — smaller, hotter, and it documents the query's shape. Your
idx_outbox_unpublished ON outbox_events(created_at) WHERE published_at IS NULL— the relay's backlog scan only ever touches unpublished rows; published rows (the vast majority over time) aren't indexed at all. This is a genuinely senior trick and it's in your migrations (006). - Unique constraints are indexes —
@@unique([userId, idempotencyKey])is not just integrity, it's the duplicate-check lookup path. - Index the FK columns — Postgres does NOT auto-index FKs; unindexed FKs make cascades and parent-child joins O(child). Yours:
idx_users_manager_id(the CTE's join path). - Covering via INCLUDE — when a hot query needs 1–2 extra columns; know the term even if you didn't need it.
- HNSW for vectors — approximate nearest-neighbor graph; your
vector_cosine_opsindex keeps similarity search ms-scale (Day 66).
Traverse's index story — with the war story
The schema's index set: Revision indexes on [userId, scheduledFor], [userId, completedAt], [userId, type], [modelVersion] — each maps to a named query ("due today," "history," "analytics per version"). Notification on [userId, createdAt], [userId, readAt], [userId, coalesceKey, readAt] — the third one exists specifically for coalescing lookups (find this user's unread row with this key) — an index designed for one function's access path. That's the discipline: indexes have client names.
The war story (streak walk): the old calculateCurrentStreak issued one findUnique per day inside a while loop — a 300-day streak = 300 round-trips — and it ran on every solve. The rewrite fetches the full history in two queries and walks contiguity in memory. The lesson isn't "batch your queries" — it's that the index was fine; the query shape was broken. Indexes can't save a chatty access pattern. Say it exactly like that.
What NOT to index
Write-heavy tables (submissions!) gain read speed at write cost — traverse indexes submissions selectively (the unique constraint is mandatory; the rest live on derived tables). Zoth's api_keys.key_hash has a unique constraint (implying the index) — no extra index needed. And every index must be nameable to a query: "which query uses this?" — no answer, no index.
Drill
SELECT * FROM access_requests WHERE tenant_id=$1 AND status='ACTIVE' AND expires_at < now() (the expiry loop) — ideal index: (status, expires_at) or partial WHERE status='ACTIVE' (expires_at). Your actual: idx_access_requests_status + batch LIMIT 100 — fine at this scale; be able to propose the better one and say why the difference is negligible at 100 rows/tick.
Bulletproof your resume
Say these answers aloud before moving on
These are the follow-ups an interviewer will actually ask about your projects.
Explain the partial index in your outbox table.
CREATE INDEX idx_outbox_unpublished ON outbox_events(created_at) WHERE published_at IS NULL. The relay scans only unpublished events ordered by created_at; once published, rows leave the index entirely. So the index stays tiny no matter how many events accumulate — it's proportional to the backlog, not the history. It's the poster child for matching an index to a query predicate exactly, and it also documents the relay's contract in the schema itself.
Why did your 300-query streak walk not get fixed with an index?
The StreakDay PK (userId, date) was already the perfect index for each single-day lookup — the problem was the access pattern: 300 sequential round-trips, each ~0.5 ms network, on every solve. Indexes fix per-query scans; they can't fix query count. The rewrite loads the whole history once (two queries) and computes contiguity in memory — latency fell from O(days×RTT) to O(2×RTT). I now audit new features for round-trip count before anything else.
How do you decide between (user_id, status) and (status, user_id)?
By the queries: if the common filter is 'this user's requests, any status' then user_id leads; if it's 'all ACTIVE requests system-wide' (a batch job), status leads. Mine is user-first because the user-facing list endpoint dominates; the expiry loop filters status+expires but at LIMIT 100 it barely matters. The general rule: equality-most-selective-first for AND-ed predicates, and add a dedicated composite when a named query justifies its write cost.