SQL Interview Questions — Joins, Window Functions, Optimization, and the Real Patterns Companies Test
SQL rounds appear in data science, data engineering, analytics, and backend SWE loops. The same LEFT JOIN problem comes up in every one of them. Knowing the patterns matters more than knowing every function in the docs.
A SQL round is rarely about syntax. The interviewer has a list of patterns they expect to see, and they probe whether you reach for the right one. Anti-joins, window functions with proper frames, gap-and-island, top-N-per-group, recursive hierarchy traversal, partial indexes, JSONB payloads — these are the actual rubric items. If you can name the pattern, write the query, and explain the trade-off in two sentences, you will pass almost any SQL round at almost any company.
This page is organized by round structure, not by difficulty. Each section opens with what the interviewer is actually testing, then walks through the canonical questions with full, runnable SQL and 100-200 word explanations. The queries target Postgres syntax by default; differences for MySQL, BigQuery, and Snowflake are called out where they matter.
What SQL rounds test
JOINs, especially LEFT and FULL OUTER. Anti-joins, multi-table fan-outs, self-joins. Cardinality bugs are the most common silent failure in production SQL, and interviewers know it.
Aggregations with HAVING. Where do you filter, when do you push predicates down, when does COUNT(DISTINCT) become the bottleneck. Three questions, one rubric.
Window functions. ROW_NUMBER versus RANK versus DENSE_RANK; LAG and LEAD for time series; running totals with explicit frames; FIRST_VALUE and LAST_VALUE without the frame trap.
CTEs, including recursive. Org hierarchies, session reconstruction, gap-and-island. CTEs are the readability tool; recursion is the senior signal.
Query optimization. Index design, EXPLAIN plan reading, anti-patterns. The expected answer is the mental loop, not the right query on the first try.
Occasionally, schema design. Open-ended entity decomposition. Walked through the same way you would walk a system design question — entities, access patterns, normalization trade-offs, partitioning.
JOINs
Every SQL round opens with a JOIN. The named JOIN types — INNER, LEFT, RIGHT, FULL OUTER, CROSS — are easy. The pattern recognition is what gets tested: anti-joins, self-joins, cardinality bugs, multi-table fan-outs.
Find users who have never placed an order.
SELECT u.user_id, u.email
FROM users u
LEFT JOIN orders o
ON o.user_id = u.user_id
WHERE o.order_id IS NULL;
The classic anti-join. The instinct most candidates show first is a NOT IN subquery, which silently breaks when the inner query returns NULLs (NOT IN with a NULL row evaluates to UNKNOWN and the outer row is dropped). LEFT JOIN with WHERE right.id IS NULL is the safe form and usually the most readable. NOT EXISTS is also correct and sometimes faster, because the planner can short-circuit on the first match. In an interview, name all three options, pick the LEFT JOIN, and call out the NOT IN NULL trap. That single observation distinguishes someone who has shipped SQL from someone who has only studied it.
Find pairs of users who live in the same city (no duplicates, no self-pairs).
SELECT a.user_id AS user_a,
b.user_id AS user_b,
a.city
FROM users a
JOIN users b
ON a.city = b.city
AND a.user_id < b.user_id;
Self-joins look strange the first time you see one, but they are just the same table aliased twice. The trick is the user_id < condition, which eliminates the (a, a) self-pair and de-duplicates (a, b) versus (b, a). Without that condition you would emit every pair twice plus every user paired with themselves. If the city column is high-cardinality and the table is large, this query benefits from an index on city — the join becomes a lookup on equal city values. Mention the index when asked about scale; interviewers love when you connect query shape to physical layout.
Find the third highest distinct salary in the employees table.
-- Window function approach (preferred)
SELECT salary
FROM (
SELECT salary,
DENSE_RANK() OVER (ORDER BY salary DESC) AS rnk
FROM employees
) t
WHERE rnk = 3;
-- Subquery approach (older portable form)
SELECT MAX(salary) AS third_highest
FROM employees
WHERE salary < (
SELECT MAX(salary)
FROM employees
WHERE salary < (SELECT MAX(salary) FROM employees)
);
Two valid answers, two different conversations. With DENSE_RANK you handle ties correctly — three people tied for second place still produce a meaningful third rank, where ROW_NUMBER would skip ranks and RANK would leave gaps. The subquery approach is the answer you give if the interviewer says no window functions. Always ask the clarifying question first: does third highest mean third distinct salary, or third row when ordered? That question alone earns points. If they want top-N for arbitrary N, the window-function form generalizes; the nested-MAX form does not.
Reconcile two systems: list every customer that exists in either CRM or Billing, with the source.
SELECT COALESCE(c.customer_id, b.customer_id) AS customer_id,
c.email AS crm_email,
b.email AS billing_email,
CASE
WHEN c.customer_id IS NULL THEN 'billing_only'
WHEN b.customer_id IS NULL THEN 'crm_only'
ELSE 'both'
END AS source
FROM crm_customers c
FULL OUTER JOIN billing_customers b
ON c.customer_id = b.customer_id;
FULL OUTER JOIN is the question that filters the people who learned SQL on toy datasets from people who have done real reconciliation work. The COALESCE on the join key is essential — without it, the right-only rows have NULL on the left and you cannot reference a single customer_id column. The CASE classifies each row, which is exactly what data engineers do during system migrations. MySQL does not have FULL OUTER JOIN; if the interview is on MySQL, simulate it with a LEFT JOIN UNION RIGHT JOIN. Postgres, Snowflake, BigQuery, SQL Server all support it natively.
A junior engineer writes JOIN a JOIN b JOIN c and gets ten times the expected row count. Why?
-- Wrong: order_items has many rows per order, payments has many rows per order
SELECT o.order_id, oi.product_id, p.amount
FROM orders o
JOIN order_items oi ON oi.order_id = o.order_id
JOIN payments p ON p.order_id = o.order_id;
-- Right: aggregate before joining, or join along a single chain
SELECT o.order_id, oi.product_id, p_total.amount
FROM orders o
JOIN order_items oi ON oi.order_id = o.order_id
JOIN (
SELECT order_id, SUM(amount) AS amount
FROM payments
GROUP BY order_id
) p_total ON p_total.order_id = o.order_id;
Row multiplication is the most common silent bug in production SQL. When you join a parent table to two child tables that each have many rows per parent, you produce the cross product of the two children for every parent — N order_items times M payments per order. The fix is either to aggregate one side first (so it becomes one row per order), or to query the two children separately and union or report them independently. The deeper lesson: every JOIN should be classified as one-to-one, one-to-many, or many-to-many before you write it, and many-to-many should always raise a flag.
Window functions
Window functions are the single highest-leverage skill in modern SQL. ROW_NUMBER, RANK, LAG, LEAD, FIRST_VALUE, plus running aggregates with frames. If a candidate cannot describe the difference between RANK and DENSE_RANK, the round usually ends there.
Compute a running total of order amount per user, ordered by order date.
SELECT user_id,
order_id,
order_date,
amount,
SUM(amount) OVER (
PARTITION BY user_id
ORDER BY order_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_total
FROM orders;
The running total is the textbook window function. PARTITION BY isolates each user; ORDER BY defines the running direction; the frame clause specifies the window of rows considered. Many candidates omit the frame clause and rely on the default — and the default is dangerous. For aggregate functions like SUM, the default frame when ORDER BY is present is RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, which behaves correctly until you hit ties on order_date, and then it lumps all tied rows together. Always specify ROWS explicitly when you want a row-by-row running total. Saying that out loud earns the round.
Rank products by total revenue within each category.
SELECT category,
product_id,
revenue,
RANK() OVER (
PARTITION BY category
ORDER BY revenue DESC
) AS revenue_rank
FROM (
SELECT p.category,
p.product_id,
SUM(oi.quantity * oi.unit_price) AS revenue
FROM products p
JOIN order_items oi ON oi.product_id = p.product_id
GROUP BY p.category, p.product_id
) t;
The aggregation has to happen before the ranking — you cannot RANK rows that have not been summed yet. So the inner query produces one row per (category, product) with its total revenue, and the outer window applies the rank. The choice between RANK, DENSE_RANK, and ROW_NUMBER matters. RANK leaves gaps after ties (1, 2, 2, 4); DENSE_RANK does not (1, 2, 2, 3); ROW_NUMBER breaks ties arbitrarily, which is non-deterministic and a common bug source. For top-product reporting, DENSE_RANK is usually the human-readable choice. Discuss the trade-off in the interview rather than picking silently.
Find users whose weekly session count has decreased for three consecutive weeks.
WITH weekly AS (
SELECT user_id,
DATE_TRUNC('week', session_start) AS week,
COUNT(*) AS sessions
FROM sessions
GROUP BY user_id, DATE_TRUNC('week', session_start)
),
flagged AS (
SELECT user_id,
week,
sessions,
LAG(sessions, 1) OVER (PARTITION BY user_id ORDER BY week) AS prev1,
LAG(sessions, 2) OVER (PARTITION BY user_id ORDER BY week) AS prev2,
LAG(sessions, 3) OVER (PARTITION BY user_id ORDER BY week) AS prev3
FROM weekly
)
SELECT user_id, week
FROM flagged
WHERE prev3 > prev2
AND prev2 > prev1
AND prev1 > sessions;
LAG and LEAD are the time-series Swiss army knife of SQL. LAG(sessions, 1) returns the previous row's sessions value within the partition, LAG(sessions, 2) the row before that, and so on. The CTE pattern keeps the query readable: aggregate first, attach lagged columns next, filter last. A subtle interview moment — the strict inequalities (prev3 > prev2 > prev1 > sessions) require that every week be present in the data; if a user has a week with zero sessions, that week is missing entirely and the LAG skips it. Mention generate_series or a calendar table to densify weeks if the interviewer pushes on edge cases.
Get the top 3 highest-paid employees per department.
SELECT department_id, employee_id, salary
FROM (
SELECT department_id,
employee_id,
salary,
ROW_NUMBER() OVER (
PARTITION BY department_id
ORDER BY salary DESC, employee_id
) AS rn
FROM employees
) t
WHERE rn <= 3;
The top-N-per-group pattern shows up in product analytics, leaderboards, and recommendation pipelines. ROW_NUMBER is the right window function here because we want exactly 3 rows per department even if there are salary ties; RANK or DENSE_RANK could return more than 3. Add a tie-breaker column (employee_id) to ORDER BY to make the result deterministic — without it, two runs on the same data can return different employees. On a large table, a covering index on (department_id, salary DESC, employee_id) lets the planner satisfy the window without a sort.
For each user, return the first and last login timestamp in the same row.
SELECT DISTINCT
user_id,
FIRST_VALUE(login_at) OVER (
PARTITION BY user_id ORDER BY login_at
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
) AS first_login,
LAST_VALUE(login_at) OVER (
PARTITION BY user_id ORDER BY login_at
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
) AS last_login
FROM logins;
FIRST_VALUE and LAST_VALUE require an explicit frame to return the actual first and last in the partition; otherwise LAST_VALUE returns the current row's value because the default frame ends at the current row. This is the single most repeated bug in window-function code. The DISTINCT collapses the per-row duplicates that the window produces. An equivalent and arguably cleaner alternative is GROUP BY user_id with MIN(login_at) and MAX(login_at), which avoids the frame trap entirely. Bring up the alternative in the interview — showing two correct paths is stronger than showing one.
GROUP BY and HAVING
Most candidates know GROUP BY. Few can articulate when WHERE versus HAVING applies, why COUNT(DISTINCT) is expensive, and how to push predicates down. These three questions probe exactly that boundary.
Find users with more than 5 orders in the last 30 days.
SELECT user_id, COUNT(*) AS order_count
FROM orders
WHERE order_date >= CURRENT_DATE - INTERVAL '30 days'
GROUP BY user_id
HAVING COUNT(*) > 5;
WHERE filters rows before grouping; HAVING filters groups after grouping. That distinction is on the rubric of every SQL screen. Putting COUNT(*) > 5 in WHERE is a syntax error because the count does not exist yet at row-evaluation time. Putting order_date filtering in HAVING is legal but slow — the engine has to group every row, including 5-year-old orders, before discarding them. Keep predicates that reference raw columns in WHERE, predicates that reference aggregates in HAVING. On large tables, also mention that an index on (user_id, order_date) lets the planner range-scan recent orders and stream the GROUP BY.
List departments whose average salary exceeds the company-wide average salary.
SELECT d.department_id,
d.department_name,
AVG(e.salary) AS dept_avg
FROM departments d
JOIN employees e ON e.department_id = d.department_id
GROUP BY d.department_id, d.department_name
HAVING AVG(e.salary) > (SELECT AVG(salary) FROM employees);
Two aggregates in one query: the per-department average in the GROUP BY, and the company-wide average in the scalar subquery. The subquery runs once and the planner caches its result, so the cost is the GROUP BY itself. A common variant — what if you also want each department's headcount and the percentage above the company average — extends naturally with COUNT(*) and arithmetic in the SELECT. Avoid putting AVG(salary) > AVG(...) inside HAVING with both halves aggregated over the same scope; the scalar subquery makes the second average independent and is the cleanest read.
For each product category, count the number of distinct users who have bought from it, but only categories with at least 100 distinct buyers.
SELECT p.category,
COUNT(DISTINCT o.user_id) AS distinct_buyers
FROM orders o
JOIN order_items oi ON oi.order_id = o.order_id
JOIN products p ON p.product_id = oi.product_id
GROUP BY p.category
HAVING COUNT(DISTINCT o.user_id) >= 100;
COUNT(DISTINCT) is correct but expensive — every group has to deduplicate the user_ids before counting. On warehouses like BigQuery and Snowflake this is fine; on Postgres or MySQL at large scale it can become the bottleneck. If you see the interviewer push on performance, offer APPROX_COUNT_DISTINCT (BigQuery, Snowflake) or HyperLogLog (Postgres extension) and explain that you trade a bounded error for an order-of-magnitude speedup. The HAVING clause filters categories after the distinct count is computed; you cannot push it into WHERE because the count does not exist yet.
CTEs and recursion
CTEs make queries readable. Recursive CTEs solve hierarchy traversal, sessionization, and gap-and-island problems that look impossible without them. These are the questions that signal a senior IC.
Given an employees table with a manager_id column, return every employee's chain of command up to the CEO.
WITH RECURSIVE chain AS (
SELECT employee_id,
manager_id,
employee_name,
1 AS depth,
CAST(employee_name AS TEXT) AS path
FROM employees
WHERE manager_id IS NULL -- CEO
UNION ALL
SELECT e.employee_id,
e.manager_id,
e.employee_name,
c.depth + 1,
c.path || ' -> ' || e.employee_name
FROM employees e
JOIN chain c ON e.manager_id = c.employee_id
)
SELECT employee_id, employee_name, depth, path
FROM chain
ORDER BY depth, employee_name;
Recursive CTEs have an anchor (the WHERE manager_id IS NULL row that seeds the recursion) and a recursive term that joins back to the CTE itself. The engine repeats the recursive term until it produces zero new rows. The depth column tracks how many levels deep we are; the path column accumulates the human-readable chain. A real-world hazard: cycles. If two employees mistakenly manage each other, the recursion never terminates. Postgres lets you set a cycle clause; otherwise add a depth cap with a WHERE depth < 50 in the recursive term. Always mention cycle protection — interviewers love seeing engineers think about adversarial data.
Reconstruct user sessions from raw event timestamps, where a new session starts after 30 minutes of inactivity.
WITH gaps AS (
SELECT user_id,
event_time,
CASE
WHEN event_time - LAG(event_time) OVER (
PARTITION BY user_id ORDER BY event_time
) > INTERVAL '30 minutes'
THEN 1 ELSE 0
END AS is_new_session
FROM events
),
sessioned AS (
SELECT user_id,
event_time,
SUM(is_new_session) OVER (
PARTITION BY user_id ORDER BY event_time
) AS session_index
FROM gaps
)
SELECT user_id,
session_index,
MIN(event_time) AS session_start,
MAX(event_time) AS session_end,
COUNT(*) AS event_count
FROM sessioned
GROUP BY user_id, session_index;
Sessionization is one of the highest-signal SQL questions for analytics and DE roles. The pattern is gap-and-island: flag where a new island begins (the 30-minute gap), then run a cumulative sum to assign a stable session_index to all events in the same island. The final GROUP BY collapses the events into one row per session. This pattern shows up in fraud detection, attribution, and user-journey analysis. If the interviewer pushes deeper, discuss the trade-offs of a fixed gap threshold versus session-aware models, and mention that some warehouses (BigQuery's SESSION_USER_ID, Snowflake's QUALIFY) provide higher-level constructs.
Find users who have been active for at least 7 consecutive days.
WITH days AS (
SELECT DISTINCT user_id, DATE(event_time) AS day
FROM events
),
streaks AS (
SELECT user_id,
day,
day - INTERVAL '1 day' * ROW_NUMBER() OVER (
PARTITION BY user_id ORDER BY day
) AS streak_group
FROM days
)
SELECT user_id,
MIN(day) AS streak_start,
MAX(day) AS streak_end,
COUNT(*) AS streak_days
FROM streaks
GROUP BY user_id, streak_group
HAVING COUNT(*) >= 7;
The day - row_number trick is the islands pattern in disguise. For consecutive days, day minus its row number is constant across the streak — the moment a gap appears, the constant changes, and grouping by it collapses each streak into a row. This is one of those SQL idioms that looks obscure until you see it once, and then it shows up everywhere. Variants include consecutive months, consecutive logins, consecutive wins. Be ready to walk the interviewer through why the constant is constant — that explanation is the actual signal they are testing.
Query optimization
Indexes, EXPLAIN plans, anti-patterns. Optimization questions are where DE and senior backend SWE rounds separate from junior ones. The expected answer is not a correct query — it is the correct mental loop.
How would you index this slow query that scans 50M rows?
SELECT user_id, order_id, total
FROM orders
WHERE status = 'shipped'
AND created_at >= NOW() - INTERVAL '7 days'
ORDER BY created_at DESC
LIMIT 100;
Walk through it predicate by predicate. The two filters are status (low cardinality) and created_at (high cardinality, range). The ORDER BY is on created_at DESC. A composite index on (status, created_at DESC) is the strongest candidate — the planner can seek to status='shipped', then range-scan created_at in the desired order, and stop after 100 rows. Adding included columns (user_id, order_id, total) makes it a covering index and avoids the heap fetch entirely. Discuss the trade-off: indexes accelerate reads but cost on writes, especially on a hot table like orders. Always ask the read/write ratio before recommending index changes — that question alone is a senior signal.
What is wrong with this query?
SELECT *
FROM users
WHERE LOWER(email) = 'shekhar@example.com'
AND created_at::date = '2026-05-05'
AND id IN (SELECT user_id FROM banned_users);
Three anti-patterns in five lines. First, SELECT * pulls every column even when you need two — wasteful network and forced heap fetch. Second, LOWER(email) and created_at::date wrap columns in functions, which kills any index on those columns; the planner cannot use a B-tree on email if it has to evaluate LOWER on every row. Fix by storing emails lower-cased on insert, or by creating a functional index on LOWER(email). For created_at::date, rewrite to a range: created_at >= '2026-05-05' AND created_at < '2026-05-06'. Third, IN (subquery) is correct but often slower than EXISTS or a JOIN, especially when the subquery returns many rows; show the planner an EXISTS and let it pick.
An EXPLAIN plan shows a Seq Scan with a Filter and 80,000 rows returned out of 50M. What do you do?
EXPLAIN ANALYZE
SELECT order_id, total
FROM orders
WHERE customer_id = 12345 AND status = 'shipped';
-- Plan output excerpt:
-- Seq Scan on orders (cost=0.00..1234567.00 rows=80000 width=16)
-- Filter: ((customer_id = 12345) AND (status = 'shipped'))
-- Rows Removed by Filter: 49920000
Seq Scan on a 50M-row table to return 80K rows is a textbook missing-index situation. Read the plan top-down: the Seq Scan is the access method, the Filter is the post-scan predicate, the Rows Removed by Filter is the throwaway. A composite index on (customer_id, status) would convert the Seq Scan to an Index Scan or Index Only Scan and reduce the scan to roughly 80K row touches plus root walks. After creating the index, run EXPLAIN ANALYZE again — note the difference between estimated and actual rows; large divergence suggests stale statistics and you should ANALYZE the table. Talk through the loop: read plan, identify dominant cost, propose index, verify, repeat. That loop is the actual job.
Schema design
Schema design rounds are open-ended on purpose. The interviewer wants to see how you decompose entities, where you normalize, where you denormalize for read performance, and what you leave out for a follow-up.
Design a schema for a ride-sharing app.
-- Core entities
CREATE TABLE users (
user_id BIGSERIAL PRIMARY KEY,
phone TEXT UNIQUE NOT NULL,
email TEXT UNIQUE,
role TEXT NOT NULL CHECK (role IN ('rider','driver','both')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE drivers (
user_id BIGINT PRIMARY KEY REFERENCES users(user_id),
license_number TEXT UNIQUE NOT NULL,
rating NUMERIC(3,2),
status TEXT NOT NULL CHECK (status IN ('offline','available','on_trip'))
);
CREATE TABLE vehicles (
vehicle_id BIGSERIAL PRIMARY KEY,
driver_id BIGINT NOT NULL REFERENCES drivers(user_id),
plate TEXT UNIQUE NOT NULL,
vehicle_class TEXT NOT NULL -- 'standard','xl','luxury'
);
CREATE TABLE trips (
trip_id BIGSERIAL PRIMARY KEY,
rider_id BIGINT NOT NULL REFERENCES users(user_id),
driver_id BIGINT REFERENCES drivers(user_id),
vehicle_id BIGINT REFERENCES vehicles(vehicle_id),
origin_lat NUMERIC(9,6) NOT NULL,
origin_lng NUMERIC(9,6) NOT NULL,
dest_lat NUMERIC(9,6) NOT NULL,
dest_lng NUMERIC(9,6) NOT NULL,
requested_at TIMESTAMPTZ NOT NULL,
picked_up_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
status TEXT NOT NULL, -- requested, matched, in_progress, completed, cancelled
fare_cents INTEGER
);
CREATE INDEX idx_trips_rider ON trips(rider_id, requested_at DESC);
CREATE INDEX idx_trips_driver ON trips(driver_id, requested_at DESC);
CREATE INDEX idx_trips_status ON trips(status) WHERE status IN ('requested','matched','in_progress');
Schema design questions reward a structured walk-through, not a dump. Start with entities — users, drivers, vehicles, trips, payments, ratings. Note that drivers extend users (a driver is a user with extra columns), so a one-to-one drivers table referencing users keeps the role flexible. Trips are the central event table; everything else hangs off them. Decide what is normalized versus denormalized — a partial index on active trip statuses keeps the dispatcher query fast even when historical trips dominate row count. Talk about hot-path latency: matching riders to drivers requires geospatial queries, so PostGIS or a separate Redis geo set sits alongside this schema. Ratings, payments, surge pricing, and promotions each get their own tables; do not stuff them into trips.
Design a schema for analytics events.
-- Wide event table, partitioned by day
CREATE TABLE events (
event_id UUID PRIMARY KEY,
event_time TIMESTAMPTZ NOT NULL,
event_name TEXT NOT NULL,
user_id BIGINT,
session_id UUID,
device_id TEXT,
app_version TEXT,
platform TEXT,
country TEXT,
-- semi-structured payload for evolving event-specific fields
properties JSONB
) PARTITION BY RANGE (event_time);
CREATE INDEX idx_events_user_time ON events (user_id, event_time DESC);
CREATE INDEX idx_events_name_time ON events (event_name, event_time DESC);
CREATE INDEX idx_events_props_gin ON events USING GIN (properties);
-- Daily partition
CREATE TABLE events_2026_05_05 PARTITION OF events
FOR VALUES FROM ('2026-05-05') TO ('2026-05-06');
Analytics events have two opposing pressures. They need to be wide enough to query without joins (every event has user_id, time, country, app_version inline), but flexible enough to accept new event types without schema migrations (the JSONB properties column). Partitioning by day is critical: most analytics queries filter by a time range, and partition pruning turns a 5-billion-row scan into a 50-million-row scan. The GIN index on properties enables key-based lookups on the JSONB blob. For a real product, the warehouse layer (BigQuery, Snowflake, ClickHouse) is usually the long-term home; OLTP is just the landing zone. Mention the lambda-style split — fast partitioned OLTP for the last 7 days, columnar warehouse for the long tail.
Practice these patterns under interview conditions
Reading SQL questions is the first 20% of preparation. The other 80% is writing them out under time pressure, with an interviewer probing the edges of your query — null handling, ties, cardinality, index usage. PhantomCode's interview copilot runs alongside live SQL rounds and surfaces the right pattern in real time, without ever appearing in screen capture.