SQL defined flat: keys and referential integrity, every join type, ACID, isolation levels and their anomalies, B-tree and clustered indexes, covering indexes, views against materialized views, CTEs against subqueries against temp tables, LAG and LEAD, correlated subqueries, three-valued logic, execution order, plans, pruning, MERGE, lateral joins and the function families.
What a key promises, what each join returns, and the rule that keeps a foreign key honest.
Transactions & isolation
2
ACID, the four isolation levels, and the anomalies each one lets through.
Indexes, views & plans
6
How an engine finds rows: B-trees, clustered and covering indexes, views and materialized views, execution plans and pruning.
How a query is built
6
CTEs against subqueries against temp tables, correlated subqueries, lateral joins, self joins, MERGE, and the order the engine evaluates clauses.
Functions & NULL logic
3
LAG, LEAD and running totals, the three function families, three-valued logic, COALESCE and NULLIF, composite keys.
Evergreen · asked verbatim
10
The flat form, in the words interviewers use. The one-line definitions every screen opens with.
01 / 30
Joins & set opsData quality assertions
What is a foreign key, and what is referential integrity? What happens in a warehouse that does not enforce it?
Why they ask this
Every join depends on it, and warehouses quietly stopped enforcing it, which is the part candidates miss.
Say this
A foreign key is a column whose values must exist as a key in another table: `orders.customer_id` must match a `customers.id`. Referential integrity is that promise held for every row. OLTP databases enforce it on write; most cloud warehouses accept the declaration as metadata and do not check it, so a pipeline has to test it.
The reasoning
A foreign key names a parent. In PostgreSQL or MySQL, inserting an order with a `customer_id` that no customer has is rejected. Deleting a customer with orders is rejected or cascades, depending on the constraint's `ON DELETE` rule.
Referential integrity is the state where every child row has a parent. It is what lets an inner join be trusted: if integrity holds, joining orders to customers loses no orders. If it does not, an inner join silently drops orphans.
Snowflake, BigQuery and Redshift let you declare primary and foreign keys but, per their documentation, do not enforce them; the optimizer may use them as hints. A late-arriving dimension, a deleted parent, or a bad load leaves orphans that no error reports.
So in a warehouse the constraint becomes a test: count child rows whose key has no parent, and run it after every load. Then decide whether orphans block the publish or route to an unknown member.
The answer most people give
"The warehouse will reject the bad row because of the foreign key." It will not. The declaration exists for documentation and the optimizer; the check is yours.
They’ll ask next
A `LEFT JOIN` from orders to customers returns null customer names for 2 percent of rows. Which constraint is broken, and where should it have been caught?
What are the SQL join types? What do a semi join and an anti join return, and how do you write them?
Why they ask this
Inner and left are known; semi, anti and cross are where candidates stop, and they are the joins data engineers write most.
Say this
Inner returns matching rows from both sides. Left keeps every left row and fills the right with null when there is no match; right is the mirror; full keeps both. Cross returns every pair. A semi join returns left rows that have at least one match, written as `EXISTS` or `IN`, with no duplication. An anti join returns left rows with no match, written as `NOT EXISTS`.
The reasoning
An inner join is the intersection on the join condition. A left outer join is the inner join plus every unmatched left row with nulls on the right. Full outer is both sides' unmatched rows added. Cross is the Cartesian product, every row paired with every row, useful for generating combinations and dangerous by accident.
A semi join answers "which customers have orders". Written as a join it duplicates each customer once per order. Written as `WHERE EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id)` it returns each customer once and never fans out. Engines plan it as a semi join and stop at the first match.
An anti join answers "which customers have no orders": `WHERE NOT EXISTS (...)`. It is also written as a left join filtered on `o.id IS NULL`. `NOT IN` is the third form and the one that returns nothing when the subquery contains a null.
Spark names them `left_semi` and `left_anti` directly. In SQL the engine recognises the `EXISTS` shape, which is why writing a semi join as a join plus `DISTINCT` is slower and risks the wrong count.
The answer most people give
"A semi join is an inner join that returns only left columns." An inner join still duplicates the left row per match. The semi join returns it once, which is the point.
They’ll ask next
Why does `NOT IN` return zero rows when the subquery includes one null, and which form avoids it?
What is a composite key? When is it the right primary key, and what is the risk of building one by concatenation?
Why they ask this
Fact tables and bridge tables live on composite keys, and the concatenation trap is a real production bug.
Say this
A composite key is a key made of two or more columns together, such as `(order_id, line_number)`, where no single column is unique but the combination is. It is the natural primary key for line items, bridges and snapshots. Concatenating the parts into one string, `'42-7'`, risks collisions when a delimiter appears in a value and loses the ability to index and join on the parts.
The reasoning
A table's grain often needs more than one column to identify a row: one row per order per line, per customer per day, per student per course. The composite key is that set of columns declared together as the primary key.
It is right when it is the real identity of the row. A surrogate integer can still be added for joins and foreign keys, which is the usual practice in dimensional models, but the composite key is what uniqueness is tested on.
Building the key as one concatenated string is tempting and wrong. `customer_id || '-' || event_day` collides when a customer id contains a dash, cannot be range-scanned by day, and forces every consumer to parse it back. Keep the columns, and if one key is needed, hash the tuple or assign a surrogate.
Composite keys in joins need every column in the condition. Joining on one part of a two-column key is the most common source of fan-out in a data warehouse.
The answer most people give
"Concatenate the columns so there is one key to join on." That is how two different keys become one, and how the join on `order_id` alone fans out because nobody noticed `line_number` was in the string.
They’ll ask next
A uniqueness test on `(order_id, line_number)` passes, and a join on `order_id` alone doubles revenue. Explain both facts.
What are the ACID properties of a transaction? Give one example of what each one prevents.
Why they ask this
It is the oldest database question and still the fastest way to check that the candidate knows what a transaction is for.
Say this
Atomicity: all of a transaction's writes happen or none do, so a transfer cannot debit without crediting. Consistency: a transaction moves the database from one valid state to another, so constraints hold afterwards. Isolation: concurrent transactions do not see each other's half-finished work. Durability: once committed, the writes survive a crash.
The reasoning
Atomicity is the rollback guarantee. A load that inserts 200,000 rows and fails on row 150,001 leaves none of them if it ran in one transaction. Without it, a retry has to find and remove the partial load first.
Consistency means the rules are kept. Primary keys stay unique, foreign keys stay satisfied, check constraints hold, at the end of every transaction. An engine that lets a constraint break between statements is not consistent.
Isolation decides what a concurrent reader sees, and it has levels. At the strongest, serializable, the outcome is as if transactions ran one at a time. Weaker levels trade that for throughput and allow specific anomalies.
Durability is the write-ahead log. The engine writes the log to disk before it acknowledges the commit, so a power cut after the acknowledgement loses nothing. On a lakehouse the same four words apply, with the transaction log and the atomic metadata swap playing the roles.
The answer most people give
"Consistency means the data is correct." It means the declared constraints hold. A transaction can commit wrong numbers consistently.
They’ll ask next
Which ACID property does a table format provide through its atomic commit, and which one is still the writer's job?
What are the four isolation levels? Which anomaly does each one allow, and what is the default in PostgreSQL and MySQL?
Why they ask this
The table of levels against anomalies is the whole answer, and knowing one engine's default shows the candidate has read a real manual.
Say this
Read uncommitted allows dirty reads. Read committed prevents dirty reads but allows non-repeatable and phantom reads. Repeatable read prevents non-repeatable reads but, by the standard, allows phantoms. Serializable prevents all three and serialization anomalies. PostgreSQL's documented default is Read Committed; MySQL InnoDB's is Repeatable Read.
The reasoning
A dirty read sees another transaction's uncommitted write, which may be rolled back. A non-repeatable read re-reads a row and finds it changed by a committed transaction. A phantom read re-runs a query and finds new rows that another transaction committed. A serialization anomaly is a result that no serial order of the transactions could produce.
The PostgreSQL docs table maps them: read uncommitted allows all; read committed stops dirty reads; repeatable read stops non-repeatable reads too; serializable stops everything. The docs also note that PostgreSQL implements only three levels, with read uncommitted behaving as read committed, and that its repeatable read does not allow phantoms either.
Read committed, the PostgreSQL default, means each statement sees data committed before that statement began, so two reads in one transaction can differ. MySQL's InnoDB defaults to repeatable read, where the transaction sees a snapshot from its first read.
For a pipeline the practical consequence is that a long extraction at read committed can see rows committed mid-run. An incremental load should either read inside a snapshot or bound itself by a watermark captured first.
The answer most people give
"Serializable means transactions run one at a time." It means the result is as if they had. Engines run them concurrently and abort one when a conflict would break that promise.
They’ll ask next
An hour-long extract at read committed reads the same table twice and gets different counts. Which anomaly, and what fixes it?
What is an index? How does a B-tree index find a row, and which predicates can use it?
Why they ask this
It is the foundation of every "why is this query slow" conversation, and the sargability half is what separates understanding from recitation.
Say this
An index is a separate structure that maps a column's values to the rows that hold them, so the engine can find matches without scanning the table. A B-tree keeps the values sorted in a balanced tree, so a lookup descends a few levels and reaches the leaf holding the value and its row pointers. It serves equality and range predicates on the indexed column, and prefix matches, but not functions applied to the column or leading wildcards.
The reasoning
Without an index, `WHERE customer_id = 42` reads every row. With a B-tree on `customer_id`, the engine compares 42 at the root, follows the branch, and repeats for two or three levels. It arrives at a leaf page that lists where the matching rows live. A billion rows is about four page reads.
Because the leaves are sorted and linked, a range such as `BETWEEN 100 AND 200` or `> '2026-01-01'` walks the leaves in order. An `ORDER BY` on the indexed column can read in index order and skip the sort.
The index helps only when the predicate can be matched against the stored values. `WHERE lower(email) = 'x'` cannot use an index on `email`, because the stored values are not lowercased. `LIKE 'abc%'` can use it; `LIKE '%abc'` cannot. A type mismatch that casts the column defeats it too. This property is called sargability.
Every index costs writes, because each insert and update maintains it, and space. The write cost is why a table with 15 indexes loads slowly, and why warehouses use clustering and statistics instead of B-trees.
The answer most people give
"Add an index on the column in the WHERE clause and the query is fast." Only if the predicate is sargable, the column is selective, and the engine chooses it. A function on the column or a 60 percent match rate leaves the index unused.
They’ll ask next
An index exists on `created_at` and the query filters on `DATE(created_at) = '2026-11-01'`. Does it use the index, and how do you rewrite the predicate?
What is the difference between a clustered and a non-clustered index? How many of each can a table have?
Why they ask this
It is asked in exactly these words in SQL Server and MySQL shops, and the answer explains why the primary key's order matters.
Say this
A clustered index stores the table's rows themselves in the index's key order, so the table is the index; there can be only one, and in InnoDB and SQL Server it is usually the primary key. A non-clustered index is a separate structure of key values with pointers back to the rows; a table can have many. A lookup through a non-clustered index pays an extra hop to fetch the row.
The reasoning
In InnoDB every table is a clustered index on its primary key: the leaf pages hold the full rows, physically ordered by the key. Range scans on the primary key read rows in sequence. Inserts with a random key such as a UUID scatter writes across pages, which is why auto-increment keys load faster.
A secondary, non-clustered, index stores the indexed columns plus the primary key value at its leaves. A lookup finds the key in the secondary index, then looks the row up in the clustered index. Two B-tree descents instead of one.
PostgreSQL has no clustered index in this sense; its heap is unordered and every index is secondary, with `CLUSTER` as a one-time physical reorder. Warehouses replace both with sort or cluster keys that order the storage without a tree.
The choice of clustered key is a layout decision: it decides which range scans are sequential and how inserts land. Pick the key by which the data is most often read in ranges, usually time or a hot identifier.
The answer most people give
"The clustered index is the fastest index, so put it on the most-filtered column." It is the table's physical order, chosen once. Filters on other columns need secondary indexes, and a clustered key with random values slows every insert.
They’ll ask next
A UUID primary key in InnoDB makes bulk loads slow. Why, and what key design avoids it?
What is a covering index? Why does it make a query faster, and what does it cost?
Why they ask this
It is the index trick that removes the row lookup entirely, and a good test of whether the candidate knows what a lookup costs.
Say this
A covering index contains every column a query needs, in its key or as included columns, so the engine answers the query from the index alone without touching the table. It removes the per-row lookup into the table, which is the expensive part of a non-clustered index scan. It costs space and write time proportional to the columns included.
The reasoning
A query `SELECT amount FROM orders WHERE customer_id = 42` with an index on `customer_id` finds the matching entries quickly, then fetches each row from the table to read `amount`. For 10,000 matching orders that is 10,000 random reads.
An index on `(customer_id) INCLUDE (amount)`, or a composite on `(customer_id, amount)`, holds `amount` in the leaf. The engine reads the index entries and is done. The plan shows an index-only scan.
The order of key columns still matters: filters and ordering use the leading columns, included columns only need to be present. PostgreSQL's `INCLUDE` clause, SQL Server's `INCLUDE`, and MySQL's composite keys all express this.
Each included column grows the index and must be maintained on every write. Covering a wide query means copying much of the table, which is why covering indexes are built for a few hot queries, not for every report.
The answer most people give
"A covering index covers all the columns in the table." It covers the columns one query needs. Covering everything would be a second copy of the table.
They’ll ask next
The plan says index scan plus heap fetches for 2 million rows. Which change turns it into an index-only scan?
What is the difference between a view and a materialized view? When is each the right choice?
Why they ask this
The distinction decides whether a dashboard query costs a full scan or a lookup, and it is asked flat on most screens.
Say this
A view is a saved query: it stores no rows, and every read runs the query again against the base tables, so it is always current and always costs the full computation. A materialized view stores the query's result as a table and is refreshed on a schedule or incrementally, so reads are fast and the data is as old as the last refresh.
The reasoning
A view is a name for a `SELECT`. It gives a stable interface, hides a complex join, and applies security filters, and it costs nothing to create. Reading it costs what the query costs, every time, which is fine for a cheap query and ruinous for a 20-table join a dashboard runs every minute.
A materialized view runs that query once and keeps the rows. Reads are plain table scans or lookups. The cost moves to refresh: PostgreSQL's `REFRESH MATERIALIZED VIEW` rebuilds it, while Snowflake and BigQuery maintain theirs incrementally for supported query shapes. dbt's materialized view materialization wraps whichever the engine offers.
The trade is freshness against cost. A view is never stale; a materialized view is stale until the next refresh and costs storage. Pick the view when the query is cheap or must be current, and the materialized view when the same expensive query is read many times between changes.
A materialized view is a special case of the pre-aggregate: a derived table the engine knows how to keep in sync. The general case is a dbt table model refreshed by the pipeline.
The answer most people give
"A materialized view is a cached view that updates automatically." Only in engines that support incremental maintenance for that query shape. In PostgreSQL it is stale until someone runs the refresh.
They’ll ask next
A materialized view over a billion-row fact is refreshed hourly and takes 50 minutes. What are the two ways out?
What is an execution plan? What is the difference between a sequential scan and an index scan, and when is the scan the right choice?
Why they ask this
Reading a plan is the skill behind every tuning question, and "when is a full scan correct" is the follow-up that catches people who think scans are always bad.
Say this
An execution plan is the engine's chosen strategy for a query: which tables are read how, in what order, with which join algorithms, and what each step is estimated to cost. A sequential scan reads every row of a table; an index scan uses an index to reach matching rows. The scan is right when the query touches a large fraction of the table, because random index lookups cost more than reading sequentially.
The reasoning
`EXPLAIN` prints the plan; `EXPLAIN ANALYZE` runs it and prints actual rows and time per step. The plan is a tree: scans at the leaves, joins and aggregations above them, the final output at the root. Each node carries an estimated row count and cost.
A sequential scan reads pages in order, which disks and object stores do fast. An index scan descends the B-tree per lookup and then fetches rows, which is fast for few rows and slow for many, because each fetch is a random read. Engines switch to the scan somewhere around 5 to 20 percent selectivity in a row store.
The estimates come from statistics: row counts, distinct values, histograms. When statistics are stale the estimate is wrong, the engine picks the wrong join or the wrong scan, and the fix is `ANALYZE` or its equivalent, not a hint.
In a columnar warehouse the vocabulary changes: partition pruning and file skipping replace index scans, and a full scan of a few columns is normal and cheap. The plan shows bytes scanned and partitions read instead of index names.
The answer most people give
"A sequential scan in the plan means the index is missing." Often it means the engine judged the scan cheaper. Check the selectivity and the statistics before adding an index that will not be used.
They’ll ask next
The plan estimates 10,000 rows and the query returns 10 million. Which step do you suspect, and what command do you run first?
What is partition pruning in a warehouse or lake table? Which predicates allow it, and which silently defeat it?
Why they ask this
It is the single biggest cost lever in BigQuery, Athena and Spark SQL, and the defeat cases are where real bills come from.
Say this
Partition pruning is the engine skipping whole partitions, folders or file groups, because the query's filter on the partition column rules them out. It works when the filter compares the partition column directly to constants or parameters. A function wrapped around the column, a cast, a join condition, or a filter on a different column all defeat it, and the query scans everything.
The reasoning
A table partitioned by `event_date` stores each day apart. `WHERE event_date = '2026-11-01'` reads one day. `WHERE event_date BETWEEN ... AND ...` reads a range. The engine decides from metadata before touching data, which is why the bytes-scanned estimate drops before the query runs.
`WHERE DATE(event_ts) = '2026-11-01'` on a table partitioned by `event_ts` does not prune in BigQuery, because the filter is on a function of the column. Its docs say to filter on the partitioning column itself. `WHERE event_date = (SELECT MAX(...))` often cannot prune at plan time either. `CAST(event_date AS STRING) = ...` defeats it the same way a function defeats an index.
Pruning on a join needs the partition filter on the partitioned table's own column, or dynamic partition pruning, which Spark and the warehouses do only in some shapes. A filter applied after the join does not help the scan.
The partition column should be the one most queries filter on, usually the event date, at a granularity that keeps partitions in the hundreds of MB. Over-partitioning produces the small-files problem; under-partitioning prunes nothing.
The answer most people give
"The table is partitioned by date, so date queries are cheap." Only if the filter is on the partition column in a form the planner can match. Wrap the column in a function and the whole table is scanned at full price.
They’ll ask next
A 6 TB table partitioned by `event_date` bills the full 6 TB for `WHERE DATE(event_ts) = '2026-09-01'`. Rewrite the predicate.
What is the difference between a CTE, a subquery and a temporary table? When do you use each?
Why they ask this
All three express the same logic and behave differently under the planner, and the candidate should know which one is actually materialised.
Say this
A CTE is a named subquery declared with `WITH` that the main query references; a subquery is the same thing inline. Both are part of one statement, and most engines inline them into the plan, though some materialize a CTE referenced more than once. A temporary table is a real table that exists for the session, written once and read many times, with its own statistics.
The reasoning
A CTE makes a long query readable: name each step, read top to bottom. A subquery nests the same logic inside `FROM` or `WHERE`. Logically they are equivalent, and a recursive CTE is the one thing only a CTE can express.
Whether a CTE is a cost depends on the engine. PostgreSQL inlines a CTE referenced once since version 12 and materializes one referenced more than once or marked `MATERIALIZED`. Snowflake and BigQuery may recompute a CTE each time it is referenced. A CTE read four times can mean four scans.
A temporary table breaks the statement in two. You write the intermediate result once, the engine collects statistics on it, and later queries read it like any table. It costs a write and lives only for the session, which makes it the choice for a result reused across several statements or too large to recompute.
In pipeline code the usual order is: CTEs for readability inside one model, then a temporary or staging table when a result feeds more than one statement. A persisted model comes when more than one pipeline needs it.
The answer most people give
"CTEs are always materialized, so avoid them for performance." Engine-specific and mostly outdated. Check the plan; in most modern engines a single-reference CTE is inlined and free.
They’ll ask next
A CTE is referenced four times in one query and the plan shows four scans of the base table. What are the two fixes?
What is a correlated subquery? How does it differ from an uncorrelated one, and why can it be slow?
Why they ask this
It is the shape behind `EXISTS` and behind many accidental row-by-row queries, and candidates should be able to spot it.
Say this
A correlated subquery references a column from the outer query, so logically it runs once per outer row: `WHERE amount > (SELECT AVG(amount) FROM orders o2 WHERE o2.customer_id = o1.customer_id)`. An uncorrelated subquery runs once and its result is reused. Correlated ones can be slow when the planner cannot rewrite them into a join and really does execute them per row.
The reasoning
The tell is a reference to the outer table inside the subquery. The subquery's result depends on which outer row is being evaluated, so there is no single result to compute up front.
Planners rewrite the common shapes. `EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id)` becomes a semi join. A correlated aggregate often becomes a group-by joined back. When the rewrite works, the plan is as fast as the join you would have written.
When it does not, the plan shows a nested loop with the subquery as the inner side, executed once per outer row. On a million outer rows that is a million scans. The rewrite by hand is the join or the window function: `AVG(amount) OVER (PARTITION BY customer_id)` gives each row its customer's average in one pass.
Correlated subqueries in `SELECT` are the usual culprit in slow reports: one lookup per row for a label or a latest value. A join or a window would read each table once.
The answer most people give
"Correlated subqueries are always slow." Planners turn most into joins. The slow ones are the shapes the planner cannot rewrite, and the fix is a window or a join, not a rule against subqueries.
They’ll ask next
A `SELECT` has three correlated scalar subqueries per row over a million rows. Rewrite it in one pass.
In what order does SQL logically evaluate the clauses of a `SELECT`? Why can you not use a column alias in `WHERE`?
Why they ask this
The written order and the evaluated order differ, and the alias question is the quickest test of whether the candidate knows it.
Say this
Logically: `FROM` and joins, then `WHERE`, then `GROUP BY`, then `HAVING`, then window functions, then `SELECT`, then `DISTINCT`, then `ORDER BY`, then `LIMIT`. An alias defined in `SELECT` does not exist yet when `WHERE` runs, which is why `WHERE total > 100` fails when `total` is an alias; `ORDER BY total` works because ordering runs after `SELECT`.
The reasoning
The engine starts by building the row set: the `FROM` tables joined on their conditions. `WHERE` then removes rows, one at a time, before any grouping. That is why a filter on an aggregate cannot go in `WHERE`.
`GROUP BY` collapses rows into groups, and `HAVING` filters groups, so it can reference aggregates. Window functions are computed after grouping and filtering, over the rows that remain, which is why a window cannot be filtered in `WHERE` without a subquery or `QUALIFY`.
`SELECT` computes expressions and names them. `DISTINCT` deduplicates the selected rows. `ORDER BY` sorts and may use aliases. `LIMIT` cuts the sorted result, which is why `LIMIT` without `ORDER BY` returns an arbitrary subset.
This is the logical order; the planner may execute differently as long as the result is the same. The logical order is what decides which names are visible where.
The answer most people give
"SQL runs top to bottom, so WHERE sees the aliases above it." `SELECT` is evaluated near the end. Aliases exist for `ORDER BY` and nothing before it, in standard SQL.
They’ll ask next
Why does `WHERE ROW_NUMBER() OVER (...) = 1` fail, and what are the two ways to filter on a window?
What does the `MERGE` statement do? What are its clauses, and what makes a `MERGE` fail or misbehave?
Why they ask this
It is the idempotent write every warehouse pipeline uses, and the failure modes, duplicate source keys above all, are asked in loops.
Say this
`MERGE INTO target USING source ON condition` compares the two row sets and applies different actions per outcome: `WHEN MATCHED THEN UPDATE` or `DELETE`, `WHEN NOT MATCHED THEN INSERT`, and in some engines `WHEN NOT MATCHED BY SOURCE`. It fails, or picks an arbitrary row, when the source has more than one row matching a single target row, and it is not idempotent if the condition does not identify the row.
The reasoning
The `ON` condition is the key: it says which source row corresponds to which target row. For each target row, the engine finds matching source rows and runs the matched clause; for each source row with no target match, the not-matched clause. One statement, one transaction.
Each clause can carry an extra condition, `WHEN MATCHED AND source.updated_at > target.updated_at THEN UPDATE`, which is how a merge skips stale updates. `WHEN NOT MATCHED BY SOURCE THEN DELETE` turns a merge into a full sync that removes rows absent from the source.
The classic failure is a non-unique source: two source rows for one target key. Standard SQL raises an error; some engines update twice or pick one, which makes results depend on order. Deduplicate the source first, keeping the latest by a version or timestamp.
A merge is idempotent only when the `ON` condition is the row's identity and the actions produce the same state on a repeat. Matching on a partial key fans out; inserting on every run with a loose condition duplicates. On a lakehouse, the merge rewrites the files that hold matched rows, so clustering the target on the key keeps it cheap.
The answer most people give
"`MERGE` is an upsert, so re-running it is always safe." Only if the source is unique on the merge key and the key is the real identity. A duplicated source row is the most common reason a merge errors or double-applies.
They’ll ask next
A CDC batch holds three changes for the same order. What must happen before the `MERGE`, and which row should win?
What is a lateral join (`CROSS APPLY` in SQL Server)? What can it do that a normal join cannot?
Why they ask this
It is the join senior SQL writers reach for top-N-per-group and per-row expansions, and it shows depth when a candidate knows it.
Say this
A lateral join lets the right side of a join reference columns from the left side, so the right subquery is evaluated once per left row: `FROM customers c CROSS JOIN LATERAL (SELECT ... FROM orders o WHERE o.customer_id = c.id ORDER BY o.created_at DESC LIMIT 3) t`. It returns the latest three orders per customer in one join, which a normal join cannot express because its right side cannot see the left row.
The reasoning
In a normal join both sides are computed independently and then matched. The subquery on the right cannot use `c.id`, so "the last 3 orders for this customer" has no place to say which customer. A lateral subquery is correlated by design and runs per left row.
That gives per-row operations: top-N per group with a `LIMIT` inside, calling a table-valued function with the row's values, or unnesting an array column into rows. PostgreSQL spells it `LATERAL`; SQL Server spells it `CROSS APPLY` and `OUTER APPLY` for the left-join form; BigQuery's `UNNEST` and Snowflake's `FLATTEN` are the same idea for arrays.
The cost is per-row evaluation. With an index on `(customer_id, created_at)` each lateral lookup is cheap. Without one, it is a nested loop of scans, and a window function with `ROW_NUMBER` over the whole orders table is faster.
`CROSS JOIN LATERAL` drops left rows with an empty right result; `LEFT JOIN LATERAL ... ON true` keeps them, which is the form to use when some customers have no orders.
The answer most people give
"A lateral join is a cross join." A cross join pairs every row with every row. A lateral join evaluates the right side per left row and pairs each left row only with its own results.
They’ll ask next
Top 3 orders per customer: when would you choose the lateral form over `ROW_NUMBER() OVER (PARTITION BY ...)`?
What is a self join? Give two problems it solves, and say what you must do to make it work.
Why they ask this
Hierarchies and comparisons within one table are everyday data work, and the alias requirement is the detail that trips people.
Say this
A self join joins a table to itself, treating it as two tables through two aliases: `FROM employees e JOIN employees m ON e.manager_id = m.id`. It solves one-level hierarchies, such as employee to manager, and row-to-row comparisons, such as pairing each order with the previous one. The aliases are mandatory, and the condition must avoid pairing a row with itself when that is not wanted.
The reasoning
The table appears twice in `FROM` under different names. Every reference must say which copy it means, or the engine cannot tell `id` from `id`. `ON e.manager_id = m.id` reads the manager's row for each employee.
For comparisons within a group, the condition pairs rows by a shared key and an inequality. `ON a.customer_id = b.customer_id AND a.order_date < b.order_date` pairs each order with every later order of the same customer. Adding `AND a.id <> b.id` avoids the row matching itself on an equality key.
A self join walks one level. A hierarchy of unknown depth needs a recursive CTE, which is a self join repeated until no new rows appear. Sequential comparisons such as "the previous order" are usually cheaper with `LAG` than with a self join plus a `MAX` to pick the nearest.
Row counts multiply: a self join on an inequality within a customer with 1,000 orders produces about 500,000 pairs. Window functions exist to avoid that.
The answer most people give
"You cannot join a table to itself." You can, with aliases. The rule is that each reference names a copy.
They’ll ask next
Find customers who placed two orders within 10 minutes of each other. Self join or `LAG`, and why?
What do `LAG` and `LEAD` do? How is a running total written with a window function, and what does the frame have to do with it?
Why they ask this
They are the three window patterns every data engineer writes weekly, and the frame is the part that produces wrong running totals.
Say this
`LAG(col, n)` returns the value of `col` from `n` rows before the current row within the window's partition and order; `LEAD` looks forward. A running total is `SUM(amount) OVER (PARTITION BY customer_id ORDER BY order_date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)`. Without the `ROWS` frame the default is `RANGE`, which gives rows with equal order values the same total.
The reasoning
A window function computes across a set of rows related to the current row without collapsing them. `PARTITION BY` splits the rows into groups, `ORDER BY` orders within each group, and the function reads the neighbourhood.
`LAG(amount, 1) OVER (PARTITION BY customer_id ORDER BY order_date)` gives each order the previous order's amount, or null for the first. A third argument supplies a default. `LEAD` is the same looking forward. Differences, gaps and status transitions are all `LAG` compared to the current row.
A running total adds every row from the partition's start to the current row. That needs a frame. `ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW` means exactly those rows. The default frame when `ORDER BY` is present is `RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW`. Under it, rows that tie on the order column are peers, and all receive the total through the last peer.
So two orders on the same day get the same running total under the default frame and different totals under `ROWS`. Moving averages use `ROWS BETWEEN 6 PRECEDING AND CURRENT ROW`, and the same tie rule applies.
The answer most people give
"`SUM() OVER (ORDER BY date)` is a running total." It is, until two rows share a date. Then the default `RANGE` frame makes both rows show the sum through the second one.
They’ll ask next
Two orders share a timestamp and the running total is the same for both. Which frame fixes it, and what should the tie-breaker be?
What is the difference between a scalar function, an aggregate function and a window function? How many rows does each return?
Why they ask this
The three families are the grammar of every query, and the row-count question is the clean way to tell them apart.
Say this
A scalar function takes one row's values and returns one value for that row: `UPPER(name)`, `DATE_TRUNC('day', ts)`. An aggregate collapses many rows into one value per group: `SUM(amount)` with `GROUP BY`. A window function computes over a set of rows but returns a value for every row, keeping them all: `SUM(amount) OVER (...)`. Scalar and window preserve the row count; aggregate reduces it.
The reasoning
Scalar functions live in `SELECT`, `WHERE` and anywhere an expression goes. They see only the current row. Wrapping an indexed column in one in a `WHERE` clause is what defeats the index.
Aggregates need a grouping. Without `GROUP BY` the whole result is one group and the query returns one row. With it, one row per distinct combination of the grouped columns. A non-aggregated, non-grouped column in `SELECT` is an error, because the engine has no rule to pick one value for the group.
Window functions are aggregates or rankings applied over a window, written with `OVER`. They do not group; every input row comes out with its own computed value. That is how a row shows its own amount next to its customer's total, or its rank among them.
The same name can be both: `SUM` with `GROUP BY` is an aggregate, `SUM` with `OVER` is a window function. Ranking functions such as `ROW_NUMBER` exist only as windows.
The answer most people give
"A window function is an aggregate that also shows the detail." Close, but the difference in row count is the definition: an aggregate reduces, a window does not, and that decides where each can be used.
They’ll ask next
You need each order's amount and its share of the customer's total on one row. Which family, and why not `GROUP BY`?
What is three-valued logic in SQL? What do `COALESCE` and `NULLIF` do, and why is `NULL = NULL` not true?
Why they ask this
Null bugs are the most common silent errors in analytics SQL, and the two functions are the tools for handling them on purpose.
Say this
SQL conditions evaluate to true, false or unknown. Any comparison with `NULL` is unknown, and `WHERE` keeps only true, so `NULL = NULL` and `NULL <> 5` both filter the row out. `COALESCE(a, b, c)` returns the first non-null argument, and `NULLIF(a, b)` returns `NULL` when `a = b` and `a` otherwise, which is the standard way to avoid division by zero.
The reasoning
`NULL` means unknown, not zero and not empty. Asking whether an unknown equals 5 has no answer, so the comparison is unknown. `NOT unknown` is still unknown, which is why `WHERE col <> 'x'` drops the nulls as well as the x rows. Use `IS NULL` and `IS DISTINCT FROM` to test for null and to compare null-safely.
Aggregates skip nulls: `COUNT(col)` counts non-null values, `AVG` averages the non-null ones, and `SUM` of an all-null column is `NULL`, not 0. `COUNT(*)` counts rows regardless. Grouping and `DISTINCT` treat nulls as one group, under distinctness rather than equality.
`COALESCE(discount, 0)` substitutes a default so arithmetic does not go null. `NULLIF(denominator, 0)` turns a zero into a null so `amount / NULLIF(qty, 0)` yields null instead of an error. Together they state the business rule in the query instead of hiding it.
In joins, a null key never matches, even another null. Rows with a null foreign key disappear from inner joins and show nulls on the right of left joins, which is why dimensions carry an explicit unknown member.
The answer most people give
"Treat NULL as empty; `COALESCE` everything to zero." A zero is a value with meaning. A missing discount coalesced to 0 is right; a missing temperature coalesced to 0 is a wrong reading.
They’ll ask next
`SUM(amount)` over a partition that is all null returns null and the report shows blank. Is that correct, and what would you show?
What is the difference between DDL, DML, DCL and TCL in SQL? Give an example statement of each.
Why they ask this
A one-liner that still appears on most screens, and an easy place to lose points by mixing them up.
Say this
DDL defines structure: `CREATE`, `ALTER`, `DROP`, `TRUNCATE`. DML changes data: `INSERT`, `UPDATE`, `DELETE`, `MERGE`, and `SELECT` is usually grouped with it. DCL controls access: `GRANT`, `REVOKE`. TCL controls transactions: `BEGIN`, `COMMIT`, `ROLLBACK`, `SAVEPOINT`.
The reasoning
Data definition language creates and changes tables, views, indexes and schemas. In many engines DDL commits implicitly, so a `CREATE TABLE` inside a transaction ends it, which matters for migrations.
Data manipulation language reads and writes rows. These are the statements a pipeline runs, and they take part in transactions, so a failed `INSERT` can roll back.
Data control language grants and revokes privileges on objects to roles. Row-level security policies and column masking sit beside it in modern engines.
Transaction control language opens, commits and rolls back transactions and sets savepoints. `TRUNCATE` is the classic trick question: it empties a table like a `DELETE` but is DDL, usually faster, often not rollbackable, and does not fire row triggers.
The answer most people give
"`TRUNCATE` is DML because it removes rows." It is DDL. That is why it is fast, why it resets identity columns in some engines, and why it may commit on its own.
They’ll ask next
Why does running a migration's `CREATE TABLE` inside a transaction not protect you in MySQL?
What is the difference between `DELETE`, `TRUNCATE` and `DROP`?
Why they ask this
Asked flat on nearly every SQL screen; the rollback and speed details are what the interviewer listens for.
Say this
`DELETE` removes rows, optionally with a `WHERE`, logs each row, fires triggers and can be rolled back. `TRUNCATE` removes all rows by deallocating the table's storage, is much faster, usually resets identity counters, and in many engines cannot be rolled back. `DROP` removes the table itself, its structure, indexes and permissions.
The reasoning
`DELETE FROM orders WHERE status = 'test'` is a row operation. The engine finds each row, writes it to the transaction log, and marks it deleted. It is the only one of the three that can be selective, and the only one that respects row-level triggers and foreign-key checks per row.
`TRUNCATE TABLE orders` does not visit rows. It releases the pages and starts the table empty, which is why it takes milliseconds on a billion rows. PostgreSQL makes it transactional; MySQL and SQL Server treat it as DDL that commits.
`DROP TABLE orders` removes the definition. The data, the indexes, the constraints and the grants go with it. Recreating it means recreating all of them.
In a pipeline, `TRUNCATE` then load is the full-refresh pattern for staging tables. `DELETE WHERE` on a date range is the partition-replace pattern for row stores that have no partition overwrite.
The answer most people give
"`TRUNCATE` is just a fast `DELETE` without a `WHERE`." The speed comes from not touching rows, which is also why it skips triggers, resets counters and may not roll back.
They’ll ask next
A nightly job does `DELETE FROM fact WHERE dt = yesterday` then inserts. What does `TRUNCATE` offer instead, and what does it break?
What is the difference between `CHAR` and `VARCHAR`? Which one should you use for most columns?
Why they ask this
A warm-up question whose padding behaviour still causes join mismatches in real data.
Say this
`CHAR(n)` is fixed length: a shorter value is padded with spaces to `n` characters. `VARCHAR(n)` is variable length up to `n`, storing only the characters given. Use `VARCHAR` for almost everything; `CHAR` only for codes that are always exactly `n` characters, such as a 2-letter country code.
The reasoning
With `CHAR(10)`, the value `'abc'` is stored and returned as `'abc '` in many engines. Comparisons usually ignore trailing spaces, but string functions, concatenation and some joins across types do not. That produces the famous mismatch where `'abc'` from one table fails to equal `'abc '` from another.
`VARCHAR(10)` stores `'abc'` as three characters plus a length. It costs a little more per value and saves everything else. Modern engines optimise it well enough that fixed length buys nothing measurable.
Warehouses differ: Snowflake's `VARCHAR` has a large default limit and `CHAR` is a padded alias, BigQuery has only `STRING`, and Spark treats `CHAR` with padding semantics since 3.1. The safest column for identifiers and text is a variable-length string.
The length limit is a constraint, not a storage optimisation. Set it where the business has a rule, such as a 3-character currency code, and leave it generous otherwise.
The answer most people give
"`CHAR` is faster because it is fixed width." Rarely measurable, and the padding causes bugs. The default choice is `VARCHAR`.
They’ll ask next
A join on a code column returns nothing although both sides show `'US'`. What type difference would explain it?
What is `NULL` in SQL? How is it different from zero and from an empty string?
Why they ask this
The simplest question on the sheet and the one with the most consequences downstream.
Say this
`NULL` is the absence of a value: unknown or not applicable. Zero is a number and an empty string is a string of length zero; both are values that can be compared and summed. `NULL` compares to nothing, including itself, is skipped by aggregates, and must be tested with `IS NULL`.
The reasoning
A missing discount, a customer with no phone number, a date not yet known: these are `NULL`. The database records that there is no value, not that the value is zero or blank.
Arithmetic with `NULL` yields `NULL`: `price * NULL` is `NULL`. Comparisons yield unknown, which `WHERE` treats as false. `COUNT(col)` skips nulls and `COUNT(*)` does not. String concatenation with `NULL` yields `NULL` in standard SQL, though some engines treat it as empty.
The empty string is a real string. `'' = ''` is true; `'' IS NULL` is false, except in Oracle, where an empty string is stored as `NULL`. Zero is a real number: a balance of 0 is known to be nothing, which is different from unknown.
In a pipeline, nulls are classified: acceptable where the field is optional, invalid where it is required, and converted to an explicit default only when the business meaning is clear.
The answer most people give
"`NULL` is the same as an empty value." Empty is a value. `NULL` is the statement that no value is known, and the engine treats the two differently in every comparison and aggregate.
They’ll ask next
Why does `WHERE status <> 'closed'` leave out the rows where status is `NULL`, and what do you write instead?
What is the order of execution of a `SELECT` statement?
Why they ask this
Asked flat, often as the first SQL question, and the answer is a list the candidate should have cold.
Say this
`FROM` and `JOIN`, then `WHERE`, then `GROUP BY`, then `HAVING`, then `SELECT` with window functions evaluated before its expressions, then `DISTINCT`, then `ORDER BY`, then `LIMIT` or `OFFSET`. The written order is different, which is why aliases from `SELECT` work in `ORDER BY` but not in `WHERE`.
The reasoning
First the rows are assembled from the tables and their joins. Then `WHERE` filters rows. Then rows are grouped, and `HAVING` filters groups using aggregates.
Then the `SELECT` list is computed. Window functions run here, over the filtered and grouped rows, which is why they cannot be referenced in `WHERE` or `HAVING` without a subquery or `QUALIFY`.
`DISTINCT` removes duplicate output rows. `ORDER BY` sorts the output, and may use `SELECT` aliases because they now exist. `LIMIT` takes the first rows of the sorted result, and without `ORDER BY` takes an arbitrary set.
Engines optimise freely underneath, pushing filters down and reordering joins, but the names visible in each clause follow this logical order.
The answer most people give
"`SELECT` runs first because it is written first." It runs near the end. That single fact explains most alias and window-function errors.
They’ll ask next
Why does `LIMIT 10` without `ORDER BY` return different rows on different runs?
What is a view in SQL? What are views used for, and what is their cost?
Why they ask this
A one-liner that pairs with the materialized-view question and checks the candidate knows a view stores nothing.
Say this
A view is a named query stored in the database and used like a table. It stores no rows; every read runs its query against the base tables. Views give a stable interface over changing tables, hide complex joins, and restrict what a role can see. Their cost is that every read pays the full query, and a view over a view compounds it.
The reasoning
`CREATE VIEW active_customers AS SELECT ... FROM customers WHERE status = 'active'` saves the query under a name. Reading `active_customers` substitutes the query and runs it. Nothing is cached.
Views are the abstraction layer: a consumer reads `orders_v` while the underlying table is renamed or split. They are the security layer: grant a role the view that hides the salary column. And they are documentation: the join logic lives once, in the view.
The cost is repeated computation and opaque plans. A dashboard that reads a view built on three other views runs the whole stack every time. The planner may not optimise across the layers as well as it would a single query.
dbt's `view` materialization is this object; its `table` materialization is the same query persisted. The rule of thumb in the dbt docs is views until the read cost justifies a table.
The answer most people give
"A view is a copy of the data that stays in sync." It is a saved query. There is no copy, which is why it is always current and never fast.
They’ll ask next
Reads on a view are slow. What are the two ways to fix it, and what does each one trade away?
What is a stored procedure? How is it different from a function, and why do modern pipelines use them less?
Why they ask this
A flat question from older SQL shops that still appears, and the second half shows whether the candidate knows the modern alternative.
Say this
A stored procedure is a named block of SQL and procedural logic stored in the database and run with `CALL` or `EXEC`. It can run several statements, control transactions, loop and branch, and return result sets. A function returns a value and is used inside a query. Pipelines moved logic out of procedures into version-controlled SQL models, such as dbt, because procedures are hard to test, review and deploy.
The reasoning
Procedures were the original ETL: a nightly job that called a procedure which truncated staging, loaded, transformed and merged, all inside the database. They run close to the data and need no external scheduler for the SQL itself.
A function is narrower: it takes arguments, returns a scalar or a table, and can be called in `SELECT` or `WHERE`. It cannot usually manage transactions. Scalar functions in predicates are also a classic cause of slow queries, because the engine calls them per row and cannot use indexes.
The drawbacks of procedures are operational. Their code lives in the database, outside git unless exported, with no unit tests, no lineage, and deployment by running scripts in order. A bug is debugged by reading the procedure body in a GUI.
The modern pattern keeps SQL as files: models with explicit dependencies, tests, documentation and CI. Procedures remain for operations the engine must do in one place, such as Snowflake tasks or BigQuery scripting for administration.
The answer most people give
"A stored procedure is just a function that does not return anything." It differs in what it may do, transactions and multiple statements, and in where it is called from, not only in its return.
They’ll ask next
An old pipeline is 40 stored procedures called in order by a cron job. What do you gain by moving it to a dbt project, and what do you lose?
What is the difference between a subquery and a join? When is a subquery the better choice?
Why they ask this
Asked flat, and the answer should say that the planner often makes them the same, and name the cases where the subquery wins.
Say this
A join combines columns from two tables into one row set. A subquery is a query nested inside another, used as a value, a filter or a derived table. For existence checks, a subquery with `EXISTS` is clearer and avoids duplication; for bringing columns together, a join is the only option. Planners rewrite most subqueries into joins, so performance usually matches.
The reasoning
A join answers "show me orders with their customer's name": both tables' columns end up in the output. A subquery answers "show me customers who have orders" without needing anything from orders in the output.
Written as a join, the existence question duplicates each customer once per order and needs `DISTINCT`. Written as `WHERE EXISTS`, it returns each customer once and the planner uses a semi join, which stops at the first match. That is the case where the subquery is right.
Scalar subqueries in `SELECT`, one lookup per row, are the case where a join or a window is right. The subquery runs per row when the planner cannot rewrite it.
Derived tables in `FROM` and CTEs are subqueries used as tables, and they are joined like any other. The choice between them is readability, not performance, in most engines.
The answer most people give
"Joins are always faster than subqueries." The planner rewrites most subqueries into joins. `EXISTS` is often faster than the equivalent join plus `DISTINCT`.
They’ll ask next
Customers with no orders: write it as a subquery and as a join, and say which one breaks when `orders.customer_id` can be null.
What is the difference between a temporary table and a CTE?
Why they ask this
Asked flat after the CTE question, and the lifetime and statistics details are the answer.
Say this
A CTE is part of one statement: it exists only while that query runs, is usually inlined by the planner, and has no statistics of its own. A temporary table is a real table that lives for the session: it is written once, can be indexed and analysed, and can be read by many statements. Use a CTE to structure a query; use a temporary table to reuse a result across statements or to give the planner a materialized, measured intermediate.
The reasoning
`WITH recent AS (SELECT ...) SELECT ... FROM recent` defines `recent` for that statement only. The next statement cannot see it. The planner may inline it into the main query or materialize it, depending on the engine and how many times it is referenced.
`CREATE TEMP TABLE recent AS SELECT ...` writes the rows to session-scoped storage. Later statements query it, join it, index it. The engine gathers statistics on it, so a plan over a temp table is often better than over a CTE whose size the planner guessed.
Temp tables cost a write and disappear at session end, which in a connection-pooled pipeline may be sooner than expected. CTEs cost nothing to define and are the readable default.
In warehouse pipelines the temp table's role is usually taken by a staging table or an ephemeral dbt model, and the CTE's role by the model's own SQL.
The answer most people give
"A CTE is a temp table that is created automatically." A CTE is a query fragment; most engines never write it anywhere. A temp table is stored.
They’ll ask next
A query joins a CTE three times and runs slowly; the same logic with a temp table is fast. What changed for the planner?
What is a constraint in SQL? Name the main kinds, and say which ones a cloud warehouse enforces.
Why they ask this
A one-liner whose second half catches candidates who assume the warehouse behaves like Postgres.
Say this
A constraint is a rule the database enforces on a table's data: `NOT NULL`, `UNIQUE`, `PRIMARY KEY`, `FOREIGN KEY`, `CHECK` and `DEFAULT`. OLTP databases enforce all of them on every write. Snowflake, BigQuery and Redshift enforce `NOT NULL` and treat the key constraints as declared metadata, so uniqueness and referential integrity there are tests the pipeline runs.
The reasoning
`NOT NULL` rejects a missing value. `UNIQUE` rejects a duplicate in a column or set of columns. `PRIMARY KEY` is unique plus not null, one per table. `FOREIGN KEY` requires the value to exist in a parent table. `CHECK` requires a condition, such as `amount >= 0`. `DEFAULT` supplies a value when none is given.
In PostgreSQL or MySQL these are the first line of data quality: a bad row is refused at the moment it is written, and the error names the constraint.
Warehouse documentation is explicit that primary, unique and foreign keys are informational: they help the optimizer and document intent, but a duplicate key loads without error. BigQuery enforces `NOT NULL` only; Snowflake enforces `NOT NULL` and, on hybrid tables, keys.
So a pipeline replaces the missing enforcement with assertions after the load: uniqueness on the grain, referential checks to dimensions, accepted values. dbt's generic tests are exactly these constraints, run as queries.
The answer most people give
"The primary key stops duplicates in the warehouse." It does not. The duplicates load, and the next join doubles revenue. The test is yours to run.
They’ll ask next
Which four dbt generic tests map to which four constraints?