SQL & Indexing
PostgreSQL index internals and types, composite indexes, SQL aggregation, window functions, self- and anti-joins, table partitioning, and VACUUM.
22 questions
JuniorTheoryVery commonWhat is a database index and what data structure backs a default index?
What is a database index and what data structure backs a default index?
An index is an auxiliary structure that maps column values to row locations so the engine can find rows without scanning the whole table. The default index in PostgreSQL is a B-tree, which keeps keys sorted and supports equality and range lookups.
Common mistakes
- ✗Believing an index stores a full copy of the table rather than just key-to-location pointers
- ✗Thinking indexes are free — they add write overhead and disk storage on every insert and update
- ✗Assuming every index is a B-tree, ignoring that other index types exist for non-range workloads
Follow-up questions
- →Why does adding an index slow down inserts and updates on that table?
- →How does a B-tree let a range query like
WHERE age > 30use the index?
MiddleTheoryVery commonWhat is the N+1 query problem, and how do you fix it?
What is the N+1 query problem, and how do you fix it?
N+1 means one query fetches a list of N rows, then the code runs one more query per row to load related data — 1 + N round-trips, each paying network and planning cost. Fix it by fetching everything in one query: a JOIN, or a single batched WHERE id IN (...) / = ANY($1) keyed by the parent ids.
Common mistakes
- ✗Confusing N+1 with a missing index rather than excess round-trips
- ✗Loading related rows in a per-item loop instead of one batched query
- ✗Thinking a
JOINcannot replace per-row lookups across tables
Follow-up questions
- →How would you spot an N+1 pattern in a service's query logs or traces?
- →When is a batched
WHERE id IN (...)preferable to aJOINfor loading children?
MiddleCodeCommonFind customers who never placed an order
Find customers who never placed an order
LEFT JOIN Orders onto Customers on the customer id, then filter WHERE o.customer_id IS NULL — the anti-join keeps customers with no matching order. NOT EXISTS is equivalent; NOT IN works too but returns nothing if the subquery has a NULL, so prefer LEFT JOIN ... IS NULL or NOT EXISTS.
Common mistakes
- ✗Using
INNER JOINwithIS NULL, which can never match - ✗Trusting
NOT INwhen the subquery may containNULL - ✗Forgetting the
IS NULLtest must be on the joined (right-side) column
Follow-up questions
- →Why does
NOT INreturn no rows when the subquery yields aNULL? - →How do
LEFT JOIN ... IS NULLandNOT EXISTScompare on performance?
MiddleTheoryCommonWhat is a B-Tree and what is its lookup and insert complexity in a database?
What is a B-Tree and what is its lookup and insert complexity in a database?
A B-tree is a balanced, sorted, multi-way search tree with high fan-out, so it stays shallow for millions of rows and is PostgreSQL's default index. Sorted keys let it serve equality, range, ORDER BY, and prefix lookups. Search, insert, and delete are all O(log n), as it self-balances by splitting and merging nodes.
Common mistakes
- ✗Saying lookups are
O(1)like a hash — aB-treeisO(log n), the trade-off for keeping keys sorted - ✗Confusing a
B-treewith a binary tree — aB-treenode holds many keys and has high fan-out, keeping it shallow - ✗Forgetting that inserts and deletes are also
O(log n), since the tree must rebalance by splitting or merging nodes
Follow-up questions
- →Why does high fan-out matter more for a disk-based index than for an in-memory tree?
- →How does a
B-treeanswer a range query likeWHERE age BETWEEN 20 AND 30?
MiddleTheoryCommonWhat is a composite multi-column index and the leftmost-prefix rule?
What is a composite multi-column index and the leftmost-prefix rule?
A composite index on (a, b, c) is one B-tree sorted by a, then b, then c. By the leftmost-prefix rule it serves filters on a leading prefix — a, a,b, a,b,c — but not b or c alone. Put equality columns first, any range column last.
Common mistakes
- ✗Expecting an index on
(a, b, c)to speed aWHEREonborcalone, with no leadinga - ✗Placing a range column before equality columns, which blocks index seeks on the later columns
- ✗Thinking a composite index is the same as three independent single-column indexes
Follow-up questions
- →Why should the equality column come before the range column in the index definition?
- →When does an index-only scan apply, and how does
INCLUDEenable it?
MiddleCodeCommonCount employees per department, including empty ones
Count employees per department, including empty ones
LEFT JOIN Employee onto Departments so every department survives, GROUP BY d.id, d.name, and use COUNT(e.id) — counting the employee column gives 0 for empty departments, whereas COUNT(*) counts the one all-NULL joined row as 1. An INNER JOIN would drop empty departments.
Common mistakes
- ✗Using
COUNT(*)and reporting1for empty departments instead of0 - ✗Using
INNER JOIN, which drops departments with no employees - ✗Counting the department key instead of the employee column
Follow-up questions
- →Why does
COUNT(e.id)return0butCOUNT(*)returns1for an empty department? - →Which table must be on the left of the
LEFT JOINfor this to work?
MiddleTheoryCommonWhat does EXPLAIN ANALYZE show, and how do you use it to diagnose a slow query?
What does EXPLAIN ANALYZE show, and how do you use it to diagnose a slow query?
EXPLAIN prints the planner's chosen execution plan — join order, scan types, and cost estimates — without running the query. EXPLAIN ANALYZE actually runs it and adds real row counts and timings, so comparing estimated versus actual rows reveals bad estimates, needless scans, or a missing index.
Common mistakes
- ✗Confusing
EXPLAIN ANALYZEwith theANALYZEcommand that updates table statistics - ✗Reading only the estimated rows and ignoring the actual counts that reveal bad estimates
- ✗Assuming a sequential scan is always bad — on a small table it beats an index lookup
Follow-up questions
- →What does a large gap between estimated and actual rows usually indicate?
- →How do you read the cost numbers attached to each
EXPLAINplan node?
MiddleCodeCommonFind the second-highest salary, handling the no-second case
Find the second-highest salary, handling the no-second case
Take MAX(salary) where salary < (SELECT MAX(salary) FROM Employee): the inner max is the top salary, so the outer max is the runner-up. With no second salary this returns a single NULL row cleanly. The ORDER BY salary DESC LIMIT 1 OFFSET 1 variant instead returns no rows.
Common mistakes
- ✗Assuming
LIMIT 1 OFFSET 1matches theMAX < MAXsubquery on tied top salaries - ✗Forgetting the
LIMIT/OFFSETform returns no rows whileMAX(... < MAX)returnsNULL - ✗Not deduplicating salaries, so duplicate top values shift the result
Follow-up questions
- →How do duplicate top salaries change the
LIMIT/OFFSETresult versus the subquery? - →Why does the
MAX(... < MAX)form returnNULLinstead of an empty set?
MiddleCodeCommonFind employees who earn more than their manager
Find employees who earn more than their manager
Join Employee to itself: alias one copy e (employee) and another m (manager) on e.manager_id = m.id, then keep rows where e.salary > m.salary. The same table appears twice under different aliases, walking the manager_id → id relationship within one table.
Common mistakes
- ✗Joining on
e.id = m.idinstead ofe.manager_id = m.id - ✗Trying to express the comparison with
GROUP BYinstead of a self-join - ✗Forgetting to alias the two copies of the same table distinctly
Follow-up questions
- →Why does the join condition use
manager_id → idrather thanid → id? - →How would a
LEFT JOINchange the result for employees with no manager?
MiddleCodeCommonTop-10 RU customers by cart total above a threshold
Top-10 RU customers by cart total above a threshold
Filter to RU rows in WHERE country = 'ru', GROUP BY customer.id, email, then sum each cart with SUM(amount * price). HAVING filters groups whose total is >= 1000 (it runs after aggregation, unlike WHERE), ORDER BY that sum DESC, and LIMIT 10 keeps the top rows.
Common mistakes
- ✗Putting the aggregate
SUM(...) >= 1000test inWHERE, which runs before grouping and rejects it - ✗Forgetting every non-aggregated selected column must appear in
GROUP BY - ✗Using
INNER JOINand silently dropping RU customers who have an empty cart
Follow-up questions
- →Why must the cart total go in
HAVINGand not in theWHEREclause? - →How would you also return each customer's item count alongside the total?
SeniorTheoryCommonWhy can HAVING reference SUM(...) but WHERE cannot, and how do LEFT vs INNER JOIN differ?
Why can HAVING reference SUM(...) but WHERE cannot, and how do LEFT vs INNER JOIN differ?
Logical order is FROM/JOIN → WHERE → GROUP BY → aggregates → HAVING → SELECT → ORDER BY. WHERE runs before grouping so it cannot reference SUM(...); HAVING runs after. INNER JOIN drops customers with no cart row, so they never form a group; LEFT JOIN keeps them with a NULL aggregate.
Common mistakes
- ✗Believing
WHEREandHAVINGrun at the same stage and both can filter on aggregates - ✗Assuming
INNERandLEFT JOINyield the same groups when some customers have no cart rows - ✗Thinking
SELECTis evaluated first, so its column aliases are usable inWHERE
Follow-up questions
- →Where does a
WHEREpredicate on a non-aggregated column run in the logical order? - →Why does
COUNT(cart_item.id)read 0 for aLEFT JOINcustomer with no cart?
MiddleCodeOccasionalFind the N-th highest distinct salary, handling ties
Find the N-th highest distinct salary, handling ties
Rank rows with DENSE_RANK() OVER (ORDER BY salary DESC) in a subquery, then filter WHERE rnk = N. DENSE_RANK handles ties — shared salaries share a rank and the next rank is not skipped, so N=2 is the second distinct salary. Use ROW_NUMBER for exactly one row, RANK to skip ranks on ties.
Common mistakes
- ✗Using
ROW_NUMBERwhen ties should share a rank (useDENSE_RANK) - ✗Assuming
LIMIT OFFSET Ndeduplicates tied salaries - ✗Confusing
RANK(skips ranks) withDENSE_RANK(no gaps)
Follow-up questions
- →How do
RANK,DENSE_RANK, andROW_NUMBERdiffer on three people sharing the top salary? - →Why must the ranking go in a subquery rather than the
WHEREclause directly?
MiddleTheoryOccasionalWhat is table partitioning, and does PostgreSQL shard out of the box?
What is table partitioning, and does PostgreSQL shard out of the box?
Partitioning splits one logical table into smaller physical child tables by a key (range, list, or hash); the planner prunes to the relevant partitions, shrinking scans and easing per-partition maintenance like VACUUM. It stays within one server. PostgreSQL does not shard across servers out of the box — that needs an extension like Citus or application-level routing.
Common mistakes
- ✗Conflating partitioning (one server, child tables) with sharding (data split across servers)
- ✗Expecting PostgreSQL to shard across machines natively without Citus or app-level routing
- ✗Assuming a query speeds up even when its predicate doesn't let the planner prune partitions
Follow-up questions
- →When does range partitioning by date help a time-series workload most?
- →Why must a query filter on the partition key for the planner to prune partitions?
MiddleTheoryOccasionalWhat index types does PostgreSQL offer and when is each appropriate?
What index types does PostgreSQL offer and when is each appropriate?
B-tree is the default — equality, ranges, ordering. Hash serves equality only. GIN indexes composite values: arrays, jsonb, full-text. GiST covers geometric, range, and nearest-neighbour searches. BRIN suits very large, physically-ordered tables.
Common mistakes
- ✗Using a
Hashindex expecting it to help range queries — it serves only equality - ✗Reaching for
B-treeon ajsonbor array column whereGINis the correct choice - ✗Adding
BRINto a small or randomly-ordered table, where it gives almost no benefit
Follow-up questions
- →Why does a
BRINindex require the table to be physically ordered to be useful? - →How does a
GINindex represent a singlejsonbdocument with many keys?
MiddleTheoryOccasionalWhat does VACUUM do in PostgreSQL, how does it work, and what are its limits?
What does VACUUM do in PostgreSQL, how does it work, and what are its limits?
Postgres MVCC leaves dead row versions behind after every UPDATE/DELETE. VACUUM reclaims that space for reuse inside the table and refreshes the visibility map and planner statistics; autovacuum runs it in the background. Plain VACUUM neither returns disk to the OS nor locks the table — only VACUUM FULL does, taking an exclusive lock and rewriting the whole table.
Common mistakes
- ✗Thinking plain VACUUM returns disk space to the OS (only VACUUM FULL does)
- ✗Believing VACUUM takes an exclusive table lock
- ✗Assuming UPDATE overwrites in place, so no dead tuples accumulate
Follow-up questions
- →Why can a long-running transaction prevent VACUUM from removing recent dead tuples?
- →What is transaction-ID wraparound, and how does VACUUM prevent it?
SeniorTheoryOccasionalWhy might the query planner skip an existing index and choose a sequential scan?
Why might the query planner skip an existing index and choose a sequential scan?
When a query returns a large fraction of the table, a sequential scan is cheaper than many random index lookups, so the planner skips the index. Stale statistics, a function or type cast on the indexed column, or very low selectivity also make the planner judge the index not worth using.
Common mistakes
- ✗Believing an existing index is always used — a high-selectivity read favours a full scan
- ✗Forgetting that a function or cast on the column disables a plain column index
- ✗Ignoring stale statistics, which make the planner mis-estimate rows and pick a scan
Follow-up questions
- →How does running
ANALYZEto refresh statistics change the chosen plan? - →Why does wrapping an indexed column in a function defeat the index?
SeniorCodeOccasionalCompute a running total of salaries by hire date
Compute a running total of salaries by hire date
Use a windowed SUM(salary) OVER (ORDER BY hire_date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW). The OVER ... ORDER BY makes the aggregate cumulative, and the explicit frame sums every row up to the current one. Add PARTITION BY dept_id for a per-department running total.
Common mistakes
- ✗Using
GROUP BY hire_dateand getting per-date sums, not a running total - ✗Omitting
ORDER BYinOVER, which makesSUMtotal the whole partition - ✗Thinking a window frame cannot reference all preceding rows
Follow-up questions
- →What does
SUM(...) OVER (ORDER BY ...)return without an explicitROWSframe clause? - →How does
PARTITION BY dept_idchange the running total?
SeniorCodeOccasionalHow do you find the highest-paid employee in each department?
How do you find the highest-paid employee in each department?
Number rows per department with ROW_NUMBER() OVER (PARTITION BY dept_id ORDER BY salary DESC) in a subquery, then keep WHERE rn = 1. PARTITION BY resets the ranking per department, so row 1 is that department's top earner. A correlated subquery on MAX(salary) per department is the alternative.
Common mistakes
- ✗Using
GROUP BY dept_id+MAX(salary)and expecting the employee name to come along - ✗Ranking globally without
PARTITION BY dept_id - ✗Using
LIMIT 1and getting only one department's top earner
Follow-up questions
- →How does
PARTITION BYdiffer fromGROUP BYin what it returns? - →When would you prefer
RANKoverROW_NUMBERfor ties at the top of a department?
SeniorTheoryOccasionalHow does the write-ahead log provide durability and crash recovery?
How does the write-ahead log provide durability and crash recovery?
The write-ahead log records every change durably to the WAL before the corresponding data pages are flushed. A commit is acknowledged once its WAL records hit disk. After a crash the engine replays committed WAL records and discards uncommitted ones, so durability holds even though dirty data pages were never written.
Common mistakes
- ✗Reversing the ordering — WAL records must reach disk before the data pages, not after
- ✗Thinking a commit waits for data pages to flush, when it only waits for the WAL records to be durable
- ✗Assuming recovery discards everything in the WAL, when it replays committed records and discards only uncommitted ones
Follow-up questions
- →What is a checkpoint, and how does it bound the amount of WAL that recovery must replay?
- →How does the same WAL stream also serve as the basis for physical replication?
SeniorDebuggingRareQueries slowed over weeks though EXPLAIN shows the index is still used — diagnose the cause
Queries slowed over weeks though EXPLAIN shows the index is still used — diagnose the cause
The index is still chosen, yet it returns 12 rows while touching 4120 buffers — the signature of bloat: MVCC dead tuples from the heavy UPDATE/DELETE traffic accumulated faster than autovacuum could reclaim them, so the heap and index are full of dead pages the scan must wade through. Fix: make autovacuum more aggressive on this table, run VACUUM, and REINDEX (or pg_repack) to rebuild the bloated index.
Common mistakes
- ✗Reading 'index is used' as proof the plan is fine, ignoring the rows-vs-buffers gap
- ✗Blaming stale statistics or a missing composite index instead of bloat
- ✗Assuming heavy buffer reads always mean an undersized cache
Follow-up questions
- →Which
pg_stat_user_tablescolumns confirm the table has too many dead tuples? - →Why does
REINDEX CONCURRENTLYmatter on a table that cannot take downtime?
SeniorDebuggingRareCode review: fix this Postgres order-status-history store
Code review: fix this Postgres order-status-history store
Five bugs. sql.Open runs per call, leaking a new pool each time — open one *sql.DB in NewStore and reuse it. The fmt.Errorf result is discarded and the method returns no error — return a wrapped error instead. The fmt.Sprintf query is SQL-injectable — use a parameterized $1..$4 query with the args passed to Exec. go db.Exec is fire-and-forget, dropping the error and ordering — call it synchronously. And there is no context — accept ctx and use ExecContext.
Common mistakes
- ✗Believing
db.Execsanitizes afmt.Sprintf-built query, so injection is impossible - ✗Thinking a fire-and-forget
go db.Execis acceptable because the insert runs 'eventually' - ✗Calling
sql.Openper request, assuming it opens and owns a single real connection
Follow-up questions
- →Why does
sql.Opennot actually open a connection, and what doesdb.Pingadd? - →How does a
$1placeholder stop injection that escaping the string cannot?
SeniorTheoryRareWhat is transaction ID wraparound, and how does freeze prevent it?
What is transaction ID wraparound, and how does freeze prevent it?
PostgreSQL stamps every row version with a 32-bit transaction id (XID), and visibility is judged by comparing XIDs in a circular space. As XIDs advance, very old ones could appear to lie in the future — wraparound — making live rows seem to vanish. VACUUM prevents it by freezing old rows: marking them visible-to-all so their original XID no longer matters.
Common mistakes
- ✗Thinking the transaction id is 64-bit and so can never run out
- ✗Confusing XID wraparound with a primary-key sequence overflowing
- ✗Not knowing VACUUM's freeze step is what guards against wraparound
Follow-up questions
- →What is
autovacuum_freeze_max_age, and why can it force an aggressive vacuum? - →Why can a very old open transaction or unused replication slot push a database toward wraparound?