Query Optimization and Execution Plans Questions
Making queries fast: reading and interpreting execution/explain plans, identifying full scans, spotting SQL anti-patterns, and rewriting queries for better performance. Covers how the planner chooses join order and access methods, and how statistics drive those choices. A core skill for anyone responsible for query performance in production.
What is a database query execution plan, and how does the query optimizer generate one? Explain what it means to read a plan, name the most common physical operators you would expect to see, and describe a real situation where understanding the plan changed how you fixed a slow query.
Sample Answer
Direct answer. An execution plan is the concrete recipe the database picked to run your query: which tables it reads, in what order, using which access method and join algorithm, and roughly how expensive it expects each step to be. The optimizer builds it by considering multiple candidate plans and picking the one with the lowest estimated cost, based on table and index statistics.
Structured elaboration. A plan is a tree of operators. Leaves are typically scans (sequential scan, index scan, index-only scan) that read data from a table or index. Interior nodes combine or transform rows: joins (nested loop, hash, merge), sorts, aggregates, filters. Execution happens bottom-up: leaves produce rows first, and those rows flow upward through the tree until the top node returns the final result set. Reading a plan means identifying which node is producing or consuming the most rows and time, not just glancing at the top-level operator.
Worked example. Say a dashboard query joins a customers table to an orders table and groups by region. A plan for it might look like: a sequential scan on orders at the bottom (because there's no index on the join or filter column), feeding into a hash join with an index scan on customers, feeding into a hash aggregate for the GROUP BY. If that dashboard suddenly gets slow after orders grows from 100K to 50M rows, the sequential scan at the bottom of the tree is very likely the answer, since its cost scales with table size while the other nodes' costs are closer to the number of rows that already survived the scan. Recognizing that shape, rather than treating the query as an opaque black box, is what lets you jump straight to "add an index that lets this become an index scan" instead of guessing.
Trade-offs and pitfalls. It is tempting to read only the very top operator of a plan (the last thing printed) and stop there; the expensive node is almost always further down, closer to the leaves, because costs accumulate as rows flow up. It's also worth remembering the plan reflects the optimizer's ESTIMATE of the best strategy, not a guarantee: if the estimates are wrong (stale statistics, skewed data), the "recipe" can be provably suboptimal even though it was picked correctly given what the optimizer believed at planning time.
A query issues a correlated subquery to compute a per-row value (for example, a running count or a most-recent-record lookup), and it does not scale. Rewrite the pattern into a form the optimizer can execute as a single set operation, and explain why the rewritten version avoids the per-row cost.
Sample Answer
Direct answer. Rewrite the correlated subquery as a join followed by an aggregation, so the database computes the whole result in one pass over both tables instead of re-running the subquery once per outer row.
Structured elaboration. A correlated subquery in the SELECT list is logically executed once for every row the outer query produces; whatever work that subquery does (a scan, an index probe, an aggregation of its own) is paid again and again, once per outer row. Replacing it with a LEFT JOIN plus a GROUP BY lets the engine instead do a single join pass across both tables and a single aggregation step, which scales as roughly (rows in both tables combined) rather than (outer rows) times (cost per subquery execution).
Worked example. I verified this with a small dataset: three customers, where customer 1 has two orders, customer 2 has one, and customer 3 has none.
-- correlated subquery: one execution per customer row
SELECT c.customer_id,
(SELECT COUNT(*) FROM orders o WHERE o.customer_id = c.customer_id) AS order_count
FROM customers c;
-- equivalent join + aggregation: one pass over both tables
SELECT c.customer_id, COUNT(o.order_id) AS order_count
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.customer_id
GROUP BY c.customer_id;
Both forms return the same result, (1, 2), (2, 1), (3, 0), confirming customer 3 correctly shows zero orders under both approaches, which is the detail most likely to break in a careless rewrite.
Trade-offs and pitfalls. The rewrite is only correct if you get two details right: use a LEFT JOIN, not an INNER JOIN, if rows with zero matches (like customer 3) should still appear in the result; and count a specific NOT-NULL column from the joined-in table (order_id), not COUNT(*), since COUNT(*) would count the single NULL-padded row a LEFT JOIN produces for a non-matching customer as one row instead of zero. Getting either of those wrong silently drops rows or overcounts them rather than raising an obvious error, which is what makes this rewrite worth verifying against a small hand-checkable example before trusting it on production data. For a handful of outer rows, or a one-off analytical query, the correlated-subquery form remains perfectly readable and fine; the join rewrite earns its complexity specifically as the outer row count grows.
Complexity
The correlated-subquery form costs roughly O(outer rows times subquery cost); the join-and-aggregate form costs roughly O(rows in both tables), a single combined pass, which is the whole point of the rewrite.
Edge cases
A customer (or equivalent outer row) with zero matches must still appear with a zero count if the original subquery form would have returned zero for it; verify this specifically, since it's the case a naive INNER JOIN rewrite silently breaks.
Write (or describe) how a LATERAL join can replace a correlated subquery when you need, for each row of an outer table, the top result from a related table (for example the most recent event per user, or the top-N per group). Explain why the LATERAL form is usually more optimizer-friendly than the equivalent correlated subquery.
Sample Answer
Direct answer. A LATERAL join lets a subquery on the right-hand side reference columns from a table on the left-hand side of the FROM clause, row by row, which is exactly what you need to compute "the top N related rows per outer row" without a correlated subquery in the SELECT list or a window function over the whole joined result.
Structured elaboration. A LATERAL subquery is evaluated once per row of whatever precedes it in the FROM clause, with that outer row's columns visible inside the subquery, similar in spirit to a correlated subquery but structured as a proper join rather than an expression in the SELECT list, which lets it return multiple rows and columns naturally, and lets the optimizer reason about it more like an ordinary join than an opaque per-row expression.
Worked example. I verified this with two customers and six orders (four for customer 1, two for customer 2), returning the top 3 orders by amount per customer:
SELECT c.customer_id, o.order_id, o.total
FROM customers c,
LATERAL (
SELECT order_id, total
FROM orders o
WHERE o.customer_id = c.customer_id
ORDER BY total DESC
LIMIT 3
) o
ORDER BY c.customer_id, o.total DESC;
This correctly returned customer 1's top three orders (80, 65, 50, correctly excluding their fourth, smaller order) and customer 2's two available orders (999, 10), confirming the LATERAL subquery's WHERE o.customer_id = c.customer_id correctly re-scoped to each outer row and its own ORDER BY ... LIMIT 3 correctly capped the result per customer, not globally across all customers.
Trade-offs and pitfalls. LATERAL is usually more optimizer-friendly than an equivalent correlated scalar subquery specifically because it's structured as a genuine per-row join the optimizer can index-nest efficiently (an index on orders(customer_id, total) makes each per-customer lookup cheap), rather than an opaque per-row expression the optimizer has less visibility into; it's also a natural fit for "top N per group" specifically because the LIMIT lives inside the LATERAL subquery, scoped per outer row, which a plain window function approach achieves differently (ranking every row, then filtering on rank) with a comparable but structurally different cost profile.
Complexity
With a supporting index on the inner table's join and sort columns, this executes as roughly (outer rows) times (a cheap, index-bounded lookup for N rows), which scales far better than materializing every related row and sorting them all before trimming to N.
Edge cases
An outer row with fewer than N matching inner rows (customer 2's two orders, in the example) correctly returns just those, with no error and no padding, which is worth confirming explicitly since a naive alternative implementation can sometimes mishandle that case.
A report that used to be correct now returns incorrect counts, and the cause turns out to be NULL values interacting badly with a join or an aggregate (for example a NOT IN against a column that can be NULL). Walk through how you would diagnose a correctness issue like this, not just a performance one, and what SQL patterns you would flag as risky going forward.
Sample Answer
Direct answer. Treat this as a correctness bug first and a performance question second: reproduce the discrepancy with a small, hand-checkable slice of data, isolate whether NULLs in the join or grouping column are the cause, and only then decide on a fix, since the fix for a correctness bug (get the right answer) is different from a performance fix (get the same answer faster).
Structured elaboration. NULL has three-valued logic in SQL: comparisons against NULL evaluate to UNKNOWN rather than true or false, which silently drops rows from equality-based joins and, notoriously, can make a whole NOT IN predicate evaluate to nothing at all if the subquery's result set contains even one NULL. To diagnose, reproduce the discrepancy on a small, deliberately-constructed sample where you can hand-count the correct answer, then narrow down which specific column and which specific operation (a join condition, a NOT IN, an aggregate that's supposed to include a NULL group) is where the count diverges from what you expect.
Once confirmed, the fix is a data-modeling and query-writing decision, not primarily a performance one: decide explicitly what SHOULD happen to NULLs in that join or filter (should an order with no assigned category be included or excluded from a report? should a NOT IN become a NOT EXISTS, which handles NULLs correctly?) and make the query say that explicitly rather than relying on default three-valued-logic behavior that happens to look right on data without NULLs and silently breaks the moment a NULL appears.
Worked example. A "customers without a completed order" report written as customer_id NOT IN (SELECT customer_id FROM orders WHERE status='completed') will silently return ZERO customers, not the correct list, the moment even one row in orders has a NULL customer_id (an orphaned or bad-data row), because SQL's three-valued logic makes the entire NOT IN evaluate to UNKNOWN once a NULL is anywhere in that subquery's result. Rewriting as NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id AND o.status='completed') is unaffected by that same NULL, since EXISTS/NOT EXISTS never has to evaluate a NULL-vs-value comparison the same problematic way.
Trade-offs and pitfalls. Once you've found and fixed one instance of this pattern, treat it as evidence there may be siblings elsewhere in the same codebase, particularly any other use of NOT IN against a column that isn't guaranteed NOT NULL, since this exact defect class tends to recur wherever that same risky pattern was copied or independently reinvented.
Complexity
The fix itself doesn't change the query's complexity class; it changes correctness, which is the more urgent property to restore first.
Edge cases
Any aggregate (COUNT, SUM, AVG) silently ignores NULL values within the aggregated column by default, which is usually correct but worth double-checking explicitly whenever a report's totals look suspiciously low; that's a related but distinct NULL pitfall from the join/membership issue above.
Compare OFFSET/LIMIT pagination to keyset (seek) pagination for a large result set. Why does OFFSET-based pagination get progressively slower as the offset grows, and what does keyset pagination need (in the data and in the index) to stay fast and to avoid missing or duplicating rows when the underlying data changes between page loads?
Sample Answer
Direct answer. OFFSET/LIMIT gets slower as the offset grows because the database still has to compute and discard every row before the offset, even though it never returns them; keyset pagination instead remembers the last row's sort-key values and asks for "the next rows after that specific point," which an index can satisfy directly regardless of how deep into the result set you are.
Structured elaboration. OFFSET n LIMIT k conceptually still has to produce and count off the first n+k rows in sorted order before returning the last k of them, so a page near the front costs little but a page a million rows in costs roughly as much as scanning a million rows, even though only k of them are returned. Keyset (or "seek") pagination instead carries forward the sort key values of the last row on the previous page and asks for rows strictly after that point in sort order; if there's an index on the sort key, this is a direct index seek to that position regardless of how many pages came before, so page 2 and page 200,000 cost roughly the same.
For correctness under a changing dataset, keyset pagination also needs a sort key (or combination of keys) that's stable and unique, typically the natural sort column plus a tie-breaking unique column like the primary key, so that rows with identical values on the primary sort column still have a well-defined, non-overlapping order across pages, which is what prevents missed or duplicated rows as new data is inserted between page loads.
Worked example. I verified this with a 20-row orders table ordered by (created_at, id). The first page (ORDER BY created_at, id LIMIT 5) returns ids 1 through 5. Rather than an OFFSET 5 LIMIT 5 for the next page, the keyset form carries the last row's key forward:
-- first page
SELECT id, created_at FROM orders ORDER BY created_at, id LIMIT 5;
-- next page: continue strictly after the last row's key
SELECT id, created_at FROM orders
WHERE (created_at, id) > (:last_created_at, :last_id)
ORDER BY created_at, id
LIMIT 5;
With :last_created_at/:last_id set to the fifth row's values, the second query correctly returns ids 6 through 10, continuing exactly where the first page left off, and would do so at the same cost whether it were the 2nd page or the 20,000th.
Trade-offs and pitfalls. Keyset pagination doesn't support jumping directly to an arbitrary page number the way OFFSET does, only "next" and, with a little more work, "previous"; for a UI that genuinely needs "jump to page 500," you either need a hybrid approach or an accepted trade-off that deep arbitrary jumps stay approximate or unsupported. It also requires the sort key to be genuinely stable and unique, or concurrent inserts near the boundary can cause a row to be skipped or repeated.
Complexity
OFFSET/LIMIT costs O(offset + limit) per page, growing linearly with how deep the page is; keyset pagination costs O(log n + limit) per page via an index seek, independent of how deep the page is.
Edge cases
Ties on the primary sort column (multiple rows with the identical created_at, for instance) must be broken by a unique secondary key in both the ORDER BY and the WHERE comparison, or rows can be skipped or duplicated across page boundaries.
Unlock Full Question Bank
Get access to all Query Optimization and Execution Plans interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.