ACID is four rules that make a transaction safe: Atomic, Consistent, Isolated, Durable. All of the work happens, or none of it does. The database stays valid. Other sessions do not see a half-done change. After a commit, the data survives a crash.
Interview Q&A
๐๏ธ SQL
Joins, windows, NULLs, and the patterns interviewers reuse.
97 questions ยท 42 theory ยท 55 coding
Q-SQL-001 What does ACID mean?
Answer
Explanation
Interviewers use ACID to check that you understand why banks and order systems use transactions. Atomic means a money transfer cannot debit one account and fail to credit the other. Consistent means constraints still hold after the commit. Isolated means two users updating the same row do not silently overwrite each other. Durable means a commit is on disk, not only in memory. Hive and Spark SQL tables are not fully ACID unless you use a transactional table format such as Hive ACID or Delta Lake.
Trap
Saying ACID is "just a backup" or listing the letters without saying what a failed statement does to the rest of the transaction.
Q-SQL-002 What are transaction isolation levels?
Answer
Isolation levels decide how much one transaction can see of another transaction that is still running. The usual four are Read Uncommitted, Read Committed, Repeatable Read, and Serializable. Lower levels allow more concurrency. Higher levels allow fewer strange reads.
Explanation
Interviewers want the trade-off: correctness versus lock wait and throughput. Read Uncommitted can see uncommitted data. Read Committed only sees committed data and is the PostgreSQL default. Repeatable Read keeps rows you already read looking the same inside one transaction. Serializable makes the result look as if transactions ran one after another. In production, most OLTP apps use Read Committed. Reporting jobs that must not see mid-load rows often use a snapshot or Repeatable Read. Spark and Hive jobs usually isolate by writing a new snapshot, not by row locks.
Trap
Memorizing the names but not knowing which anomaly each level still allows.
Q-SQL-003 What are dirty, non-repeatable, and phantom reads?
Answer
A dirty read sees a change that another transaction has not committed yet. A non-repeatable read means you read the same row twice and the values changed because another session committed. A phantom read means a new row appears, or a row disappears, in a range you already queried.
Explanation
These three bugs are why isolation levels exist. Example: you read a salary of 50,000 while another session is updating it to 60,000 and has not committed. If you see 60,000, that is a dirty read. If you see 50,000, then later 60,000 after they commit, that is a non-repeatable read. If you count 10 employees in a department and a new hire appears on the second count, that is a phantom. Interviewers ask this to see if you can debug "my report changed mid-query" bugs. PostgreSQL Repeatable Read uses snapshots, so phantoms are rarer than the textbook SQL Server picture.
Trap
Mixing up non-repeatable reads (same row changed) with phantoms (the set of rows changed).
Q-SQL-004 Why is NULL = NULL not true?
Answer
NULL means unknown, not a value. Comparing unknown to unknown is unknown, not true. So NULL = NULL is not true, and WHERE col = NULL returns no rows.
Explanation
SQL uses three-valued logic: true, false, and unknown. WHERE keeps only rows where the condition is true. Unknown is thrown away like false. This is why NULL = NULL filters nothing, and why JOIN on a nullable key drops NULL keys. Interviewers ask this because it silently empties result sets. In Hive and Spark SQL the same rule holds. Use IS NULL, IS NOT NULL, or <=> / IS NOT DISTINCT FROM when you really want NULL-safe equality.
Trap
Writing WHERE col = NULL or assuming two NULL keys match in a JOIN.
Q-SQL-005 How do you filter NULL values with IS NULL?
Answer
Use IS NULL to find missing values and IS NOT NULL to drop them. Do not use = NULL or != NULL. Those comparisons are unknown, so they never pass WHERE.
Explanation
Interviewers want the exact syntax because it is a daily bug. Example: SELECT * FROM orders WHERE shipped_at IS NULL finds open orders. COALESCE(shipped_at, current_date) fills a default for display, but it does not replace IS NULL for filtering. On large Hive and Spark tables, IS NULL on a partition column can still prune partitions. IS NULL on a nested struct field needs the field to exist; a missing field can also be NULL.
Trap
Using = NULL, <> NULL, or NOT col and thinking that handles NULLs.
Q-SQL-006 What is the difference between COUNT(*) and COUNT(column)?
Answer
COUNT(*) counts rows, including rows where columns are NULL. COUNT(column) counts only rows where that column is not NULL. COUNT(DISTINCT column) also skips NULLs.
Explanation
This is a favorite trick question. If 100 orders exist and 10 have a NULL coupon_code, COUNT(*) is 100 and COUNT(coupon_code) is 90. Interviewers ask it because dashboards go wrong when people COUNT a nullable foreign key. In production, COUNT(*) on a huge Spark table still needs a full scan unless a stored table statistic or a metadata count is used. For "how many customers used a coupon", COUNT(coupon_code) is correct. For "how many orders", use COUNT(*) or COUNT(1).
Trap
Thinking COUNT(col) and COUNT(*) are always equal, or that COUNT(DISTINCT col) includes NULL as a group.
Q-SQL-007 How does AVG treat NULL values?
Answer
AVG ignores NULLs. It divides the sum of non-null values by the count of non-null values. A NULL is not treated as zero unless you wrap the column in COALESCE.
Explanation
- Example: values 10, NULL,
- 20
AVGis 15, not - 10If missing scores should count as 0, write
AVG(COALESCE(score, 0)). Interviewers ask this because KPI averages look "too high" when NULLs are dropped. The same rule applies toSUM: NULLs are skipped, andSUMof an all-NULL set is NULL, not - 0Use
COALESCE(SUM(x), 0)when a missing group should show zero. Spark SQL and PostgreSQL both skip NULLs inAVG.
Trap
Assuming NULL means zero inside AVG, or expecting AVG of no rows to be 0 instead of NULL.
Q-SQL-008 NOT IN vs NOT EXISTS
Answer
Both can mean "rows that have no match". NOT EXISTS is usually safer. If the NOT IN list contains a NULL, the whole predicate becomes unknown and returns no rows.
Explanation
NOT IN (SELECT manager_id FROM employees) fails when any manager_id is NULL, because id NOT IN (1, NULL) is not true. NOT EXISTS (SELECT 1 FROM employees m WHERE m.manager_id = e.id) treats NULL as "no match" and still returns the non-matching rows. Interviewers ask this because anti-joins are everywhere: customers with no orders, SKUs not in the feed. In Spark, a LEFT ANTI JOIN is the clear production form and avoids the NULL trap. Prefer NOT EXISTS or LEFT ANTI JOIN over NOT IN.
Trap
Using NOT IN on a nullable column and getting an empty result with no error.
Q-SQL-009 IN vs EXISTS vs JOIN
Answer
IN checks membership in a list or subquery. EXISTS checks whether at least one matching row is found and can stop early. A JOIN returns matching rows and can duplicate the left row if the right side has many matches.
Explanation
Use IN for a small distinct list. Use EXISTS for a correlated check such as "does this customer have any paid order". Use JOIN when you need columns from both tables. Interviewers ask this because JOIN is not a drop-in for EXISTS. If one customer has three orders, JOIN triples the customer row and can break a COUNT. In Spark, a broadcast IN list or a hash semi-join (LEFT SEMI JOIN) is often the plan behind EXISTS. Duplicate-sensitive answers should use EXISTS or SELECT DISTINCT after a join.
Trap
Replacing EXISTS with JOIN and then over-counting, or using IN on a subquery that returns duplicates and NULLs.
Q-SQL-010 DELETE vs TRUNCATE vs DROP
Answer
DELETE removes rows and can use a WHERE clause. TRUNCATE empties the table and keeps the table object. DROP removes the table itself, including indexes and privileges on that table.
Explanation
Interviewers want you to know what is left after each command. DELETE fires row triggers, can be logged row by row, and can roll back in a transaction. TRUNCATE is a bulk empty. In PostgreSQL, TRUNCATE can roll back inside a transaction. In some MySQL setups it is DDL and auto-commits. Hive TRUNCATE on a managed table removes data files. DROP is gone until you recreate it. In production, prefer DELETE for a slice of partitions, TRUNCATE for a full reload of a staging table, and never DROP a shared table without a restore plan.
Trap
Saying TRUNCATE always cannot roll back, or thinking TRUNCATE can take a WHERE clause in standard SQL.
Q-SQL-011 WHERE vs HAVING
Answer
WHERE filters rows before grouping. HAVING filters groups after GROUP BY. You cannot put an aggregate such as COUNT(*) in WHERE. You can put it in HAVING.
Explanation
Example: find departments with more than 10 employees. Filter people with WHERE hire_date >= '2020-01-01', then GROUP BY dept_id HAVING COUNT(*) > 10. Interviewers ask this because mixing the two is a syntax error and a logic error. WHERE reduces the scan. HAVING runs after the aggregate, so it is later and often more expensive. In Spark, push filters into WHERE (or a subquery) so partition pruning and file skipping still happen. You may use HAVING without GROUP BY to filter a single aggregate of the whole table.
Trap
Writing WHERE COUNT(*) > 10 or using HAVING for a simple column filter that belongs in WHERE.
Q-SQL-012 UNION vs UNION ALL
Answer
UNION stacks two result sets and removes duplicate rows. UNION ALL stacks them and keeps duplicates. UNION ALL is cheaper because it skips the distinct sort or hash.
Explanation
Interviewers want you to pick the cheaper operator on purpose. If you combine 2023 sales and 2024 sales that cannot overlap, use UNION ALL. If you combine two customer lists and need unique emails, use UNION or UNION ALL plus SELECT DISTINCT. In Spark, UNION is DISTINCT and can shuffle a huge dataset. Production pipelines almost always use UNION ALL for appends. Column count and types must match. Hive and Spark name this UNION / UNION ALL; the same idea exists in PostgreSQL.
Trap
Using UNION "to be safe" on billions of rows and causing an extra shuffle, or expecting UNION to align columns by name instead of by position.
Q-SQL-013 PRIMARY KEY vs UNIQUE
Answer
A primary key uniquely identifies a row and does not allow NULL. A unique constraint also forbids duplicate values, but it may allow NULLs depending on the database. A table has one primary key and can have many unique constraints.
Explanation
Example: email can be UNIQUE while user_id is the primary key. PostgreSQL unique indexes allow multiple NULLs. SQL Server historically allowed one NULL in a single-column unique index. Hive and Spark SQL do not enforce primary keys unless you add constraints in a catalog that actually validates them. Delta and some warehouses store keys as informational. Interviewers ask this to see if you know keys are for identity and uniqueness, not only for indexes. In production, still declare keys. They document the grain even when the engine does not enforce them.
Trap
Saying UNIQUE never allows NULL, or assuming a PRIMARY KEY in Hive/Spark will reject duplicate loads.
Q-SQL-014 CHAR vs VARCHAR
Answer
CHAR(n) is fixed length and pads with spaces. VARCHAR(n) is variable length and stores only the characters you write, up to n. Use CHAR for true fixed codes. Use VARCHAR for names and free text.
Explanation
CHAR(3) for country code IN is fine. A name in CHAR(100) wastes space and makes comparison surprising because of padding. Hive and Spark SQL mostly use STRING or VARCHAR. Both are variable length on disk in Parquet. PostgreSQL CHAR still pads. Interviewers ask this as a classic types question and to see if you think about storage. In production lakes, prefer STRING/VARCHAR and a check on length. Fixed-width files are an ingest format, not a reason to use CHAR in the warehouse.
Trap
Using CHAR for long text, or thinking VARCHAR(10) and STRING reject values the same way in Hive.
Q-SQL-015 View vs materialized view
Answer
A view is a saved query. It runs the SQL each time you select from it. A materialized view stores the query result as a table and must be refreshed. Views save duplication. Materialized views save compute.
Explanation
Interviewers ask this because teams argue "just put it in a view" until the view is twelve joins and every dashboard is slow. A view always sees fresh base data. A materialized view can be stale until refresh. PostgreSQL materialized views need REFRESH MATERIALIZED VIEW. Spark and Databricks have materialized views in some catalogs. Hive has views but classic Hive has no true MV. In production, materialize expensive aggregates that many jobs read. Keep a view when the logic is simple and the tables are small or already clustered.
Trap
Thinking a view stores data, or forgetting that a materialized view can serve old numbers after a late-arriving load.
Q-SQL-016 CTE vs subquery vs temp table
Answer
A CTE (WITH clause) names a query step in the same statement. A subquery is a query nested in FROM, WHERE, or SELECT. A temp table is a real table you write, then read in later statements. Use CTEs for clarity. Use temp tables when you need statistics, indexes, or to reuse a heavy result many times.
Explanation
Interviewers want you to choose based on reuse and optimizer behavior. PostgreSQL often inlines CTEs now, so a CTE is not always an optimization fence. Spark may compute a CTE once or inline it depending on the version and config. A temp table in a warehouse can be costly if you write it to cloud storage. In production Spark, a cached DataFrame or a managed temp view is the usual "temp table". If the intermediate result is huge and used once, keep it as a CTE. If many steps or many sessions need it, persist it.
Trap
Saying CTEs always improve performance, or creating temp tables for every small subquery.
Q-SQL-017 Clustered vs nonclustered index
Answer
A clustered index is the table's sort order. The row data lives in that index. A nonclustered index is a separate structure that points back to the table rows. A table has one clustered index and can have many nonclustered indexes.
Explanation
In SQL Server and MySQL InnoDB, the primary key is usually the clustered index. Lookups by that key are fast. A nonclustered index on email stores emails plus a pointer to the clustered key. Hive and Spark do not use B-tree clustered indexes on files. The close ideas are PARTITIONED BY, CLUSTER BY / DISTRIBUTE BY, and Z-ORDER or clustering keys. Interviewers still ask the OLTP definition. In production lakes, clustering helps skip files. It does not give you a B-tree seek.
Trap
Saying a table can have many clustered indexes, or claiming Hive partition columns are the same as a SQL Server clustered index.
Q-SQL-018 When should you not add an index?
Answer
Do not index a column you never filter or join on. Do not index a tiny table. Be careful with columns that change on every write. Low-cardinality columns often do not help a B-tree. Extra indexes slow INSERT, UPDATE, and MERGE.
Explanation
Interviewers want judgment, not "indexes are always faster". A boolean is_active on a billion-row table may not be selective enough. A table that is 90% writes, 10% reads can get worse with five extra indexes. In Spark and Hive, "indexing" usually means partition columns, Z-ORDER, bloom filters, or a small broadcast lookup table. Partitioning on a high-cardinality user id creates millions of tiny files. That is also an index gone wrong. Measure with EXPLAIN and file-skipping stats before adding more.
Trap
Indexing every column "just in case", or partitioning a lake table on a unique id.
Q-SQL-019 What is EXPLAIN?
Answer
EXPLAIN shows the query plan. It tells you how the engine will join, filter, sort, and aggregate. It does not always run the query. EXPLAIN ANALYZE (where it exists) runs it and adds real times and row counts.
Explanation
Interviewers ask this because "the query is slow" is not an answer. You should look for table scans, huge shuffles, broadcasts of a large table, and wrong join types. PostgreSQL EXPLAIN ANALYZE is the production habit. Spark has EXPLAIN and the SQL UI with scan size and shuffle bytes. Hive has EXPLAIN and Tez/DAG views. Plans use estimates from statistics. If stats are stale, the plan is a guess. In production, compare estimated rows to actual rows. A 100-row estimate on a 50 million row join is a red flag.
Trap
Reading EXPLAIN once and treating cost numbers as wall-clock time, or never checking actual rows.
Q-SQL-020 Nested loop join vs hash join
Answer
A nested loop join takes rows from one side and looks up matches on the other side, often with an index. A hash join builds a hash table from the smaller side and probes it with the larger side. Nested loops are good for small lookups. Hash joins are good for large equality joins.
Explanation
Interviewers use join types to see if you understand CPU and memory, not only SQL text. Nested loop plus an index is perfect for "one order id, fetch the customer". For two big tables on user_id, a hash join or sort-merge join is the usual plan. Spark also has broadcast hash join when one side is small enough to send to every executor. If you broadcast a 20 GB dimension, you can crash the cluster. Sort-merge needs shuffle and sort. Use it when both sides are large and already partitioned on the join key.
Trap
Forcing a broadcast nested loop on two large tables, or saying hash joins need an index.
Q-SQL-021 What are 1NF, 2NF, and 3NF?
Answer
1NF means atomic values and no repeating groups. 2NF means 1NF plus every non-key column depends on the whole primary key, not part of it. 3NF means 2NF plus no non-key column depends on another non-key column.
Explanation
Interviewers still ask normalization to see if you can model data. 1NF: do not store phone1, phone2 or a comma list of tags in one cell if you need to query them. 2NF: in a table keyed by (order_id, product_id), do not store customer_name that depends only on order_id. 3NF: do not store dept_name on every employee if dept_id already points to a department table. In warehouses we often stop at 3NF for dimensions, then denormalize for speed. Hive tables can store arrays and maps. That is fine for nested events, but it is not 1NF.
Trap
Reciting "no duplicates" as 3NF, or saying every warehouse table must be 3NF.
Q-SQL-022 What is denormalization?
Answer
Denormalization copies data into fewer tables on purpose so reads need fewer joins. You trade extra storage and harder updates for faster queries. Star schemas are a common denormalized shape.
Explanation
Example: copy product_category onto every sales fact so a dashboard does not join a product table each time. Interviewers ask this because analytics SQL is full of denormalized facts. The cost is consistency. If a category is renamed, you must update many rows or wait for the next ETL. In Spark lakes, denormalized Parquet is normal. Keep a normalized source of truth, then publish wide tables. Do not denormalize OLTP checkout tables the same way, or a price change becomes a nightmare.
Trap
Calling denormalization a mistake, or denormalizing without a plan to refresh the copied columns.
Q-SQL-023 Stored procedure vs function
Answer
A stored procedure is a routine that can do many statements, including inserts and updates. A function returns a value and is used inside SQL expressions. Functions are for calculation. Procedures are for a process.
Explanation
PostgreSQL functions can also write data, so the interview answer is about intent. A function like tax(amount) belongs in SELECT. A procedure like close_month(p_month) loads tables and commits. Hive and Spark SQL have user-defined functions (UDFs), not classic stored procedures. Databricks SQL has routines in some catalogs. Interviewers ask this on warehouse teams that came from Oracle or SQL Server. In production Spark, put multi-step logic in a job (notebook, dbt, Airflow), not in a procedure that other engines cannot run.
Trap
Saying functions cannot ever change data in every database, or trying to call a procedure in a SELECT list.
Q-SQL-024 What is a trigger?
Answer
A trigger is SQL that runs automatically when a table is inserted, updated, or deleted. It can validate data, copy rows to an audit table, or maintain a summary. It runs inside the same transaction as the change.
Explanation
Interviewers ask triggers because they hide logic. A BEFORE INSERT trigger can fill updated_at. An AFTER UPDATE trigger can write a history row. That is handy and also hard to debug. Hive and Spark tables do not use OLTP triggers. You implement the same idea in the ETL job or with Delta CDF / change feeds. In production OLTP, keep triggers small. A trigger that calls a web service will lock rows and fail in strange ways. Prefer explicit application or pipeline code when many engines write the table.
Trap
Putting heavy business workflow in triggers, then wondering why a simple UPDATE is slow or double-writes rows.
Q-SQL-025 What is a deadlock?
Answer
A deadlock happens when two sessions each hold a lock the other needs. Neither can move. The database picks a victim, aborts that transaction, and returns an error. The app should retry the aborted work.
Explanation
Classic example: session A updates order 1 then order 2. Session B updates order 2 then order 1. They wait forever until the engine kills one. Interviewers ask this for OLTP jobs and also for warehouse MERGE jobs that hit the same partition. In PostgreSQL you see deadlock detected. In Spark/Delta you see concurrent transaction conflicts, which are optimistic, not row-lock deadlocks. Prevention: lock rows in the same order, keep transactions short, and avoid user waits inside a transaction. On lakes, avoid two writers on the same table without a partition split or a queue.
Trap
Saying the database waits forever, or retrying without a limit when the conflict is a bad job design.
Q-SQL-026 What is a transaction?
Answer
A transaction is a group of SQL statements that commit or roll back together. After COMMIT, the changes are durable. After ROLLBACK, it is as if they never happened. BEGIN / START TRANSACTION starts one.
Explanation
Interviewers start here before ACID and isolation. Example: insert an order header and order lines in one transaction so you never store a header with no lines. Auto-commit mode runs each statement as its own transaction, which is dangerous for multi-step loads. Spark SQL jobs often treat one write as one transaction when using Delta or Iceberg. Hive ACID tables also support transactions. In production, keep transactions short. Do not open a transaction, wait for a human, then commit.
Trap
Thinking each statement is always isolated even when auto-commit is on, or wrapping a two-hour extract in one OLTP transaction.
Q-SQL-027 What does ROLLBACK do?
Answer
ROLLBACK undoes all uncommitted work in the current transaction. The tables look like they did at the last commit. A ROLLBACK TO SAVEPOINT undoes only part of the transaction if savepoints exist.
Explanation
Interviewers want to know you can recover from a failed step. Example: you delete from staging, the next insert fails, you roll back, and staging is not left empty. TRUNCATE and DROP may or may not roll back, depending on the engine. PostgreSQL can roll back TRUNCATE. Spark job failures usually do not leave a half-written Delta commit because the table commit is atomic. In production, always decide what happens on failure: rollback the transaction, or write a poison-row table and continue.
Trap
Assuming ROLLBACK undoes a committed job, or assuming Hive DDL always rolls back.
Q-SQL-028 Window functions vs GROUP BY
Answer
GROUP BY collapses rows into one row per group. Window functions (OVER) calculate an aggregate or rank but keep the original rows. Use GROUP BY when you want a summary table. Use a window when you need both the detail and the summary.
Explanation
Example: each order row plus the customer's total spend. GROUP BY customer_id cannot keep the order id. SUM(amount) OVER (PARTITION BY customer_id) can. Interviewers ask this because it is the jump from junior to mid SQL. Windows can also rank, lag, and compute running totals. They still may shuffle in Spark, just like a group by. A common production pattern is: window to filter (rn = 1), then GROUP BY for the final metric. Do not mix GROUP BY and a window on the same select unless every non-window column is grouped or aggregated.
Trap
Using GROUP BY and then wondering where the detail columns went, or putting a window in WHERE instead of a subquery/QUALIFY.
Q-SQL-029 What does PARTITION BY do in a window?
Answer
PARTITION BY splits the rows into groups for a window function. The function restarts for each group. It is not a table partition and it does not remove rows.
Explanation
ROW_NUMBER() OVER (PARTITION BY dept_id ORDER BY salary DESC) numbers salaries inside each department. Without PARTITION BY, the window covers the whole result. Interviewers ask this because people confuse it with PARTITIONED BY on a Hive table. Table partitions control files on disk. PARTITION BY in OVER is only the window grouping. In Spark, a window with a high-cardinality partition key still shuffles by that key. Keep the partition expression the real group, such as user_id, not a timestamp at second grain unless you mean it.
Trap
Thinking PARTITION BY in a window prunes Hive/Spark table partitions, or using it when you meant GROUP BY.
Q-SQL-030 RANK vs DENSE_RANK vs ROW_NUMBER
Answer
ROW_NUMBER gives unique 1, 2, 3 even when values tie. RANK gives the same number to ties and then skips. DENSE_RANK gives the same number to ties and does not skip. For "top 1 row" use ROW_NUMBER. For "top 3 scores including ties" use DENSE_RANK.
Explanation
- Scores 100, 100, 90:
ROW_NUMBERmight be 1, 2, - 3
RANKis 1, 1, - 3
DENSE_RANKis 1, 1, - 2Interviewers ask this in every data-engineer loop. Production trap:
RANK() <= 3can return more than three rows.ROW_NUMBER() = 1needs a deterministicORDER BYor ties flip between runs. In Spark, add a unique id as a last sort key so retries stay stable. Hive and PostgreSQL share the same three functions.
Trap
Using RANK for dedup (ties keep two rows) or using ROW_NUMBER for "top 3 scores" and dropping a tied score.
Q-SQL-031 OLTP vs OLAP
Answer
OLTP is many small reads and writes on current rows, like checkout and login. OLAP is heavy scans and aggregates on history, like a sales dashboard. OLTP cares about latency and locks. OLAP cares about scan speed and throughput.
Explanation
Interviewers want a short, clear split. An orders table in Postgres that updates order status is OLTP. A Spark fact table of three years of order lines is OLAP. The SQL looks similar. The engine, indexes, and grain differ. Do not run a full-table GROUP BY on the OLTP primary. Do not use the lakehouse for single-row UPDATEs every millisecond. Hybrid systems exist, but the design still starts from the workload.
Trap
Saying OLAP means "no SQL" or that a warehouse should also be the checkout database.
Q-SQL-032 What is a correlated subquery?
Answer
A correlated subquery uses columns from the outer query. It is thought of as running once per outer row. An uncorrelated subquery runs once and returns a set.
Explanation
Example: employees whose salary is greater than the average salary of their own department. The inner AVG needs dept_id from the outer row. Interviewers ask this because a correlated subquery can be slow if it is not rewritten as a join. Spark and PostgreSQL often rewrite it to a join or a window. You should still write the join or window yourself when the logic is "compare to the group average". In production, EXPLAIN should not show a nested loop over millions of outer rows.
Trap
Writing a correlated subquery for a simple group average, when AVG(...) OVER (PARTITION BY dept_id) is clearer and usually faster.
Q-SQL-033 What is a self join?
Answer
A self join is a join from a table to itself with two aliases. You use it when a row relates to another row in the same table. Manager hierarchies and "previous event" pairs are the usual cases.
Explanation
employees e JOIN employees m ON e.manager_id = m.emp_id is the classic. Interviewers ask it because the idea is simple and the aliases are easy to mess up. You can also self-join events to find the next purchase. For "next row" problems, LEAD is often cleaner than a self join. In Spark, a self join still shuffles. If you only need the parent name, a self join is fine. If you need the whole tree, use a recursive CTE where the engine supports it.
Trap
Joining without aliases, or using an inner self join on manager_id and silently dropping the CEO whose manager is NULL.
Q-SQL-034 What is a CROSS JOIN?
Answer
A CROSS JOIN is a Cartesian product. Every row on the left pairs with every row on the right. 1,000 rows times 1,000 rows is one million rows. Use it on purpose for calendars, scenarios, and small lookup grids.
Explanation
Interviewers ask this because an accidental cross join is a production outage. A missing join condition in old comma syntax is a cross join. A real use is exploding a date spine against a list of stores to fill zeros. In Spark, a cross join of two large tables can blow memory and disk. The engine may require an explicit hint or config for large cross joins. PostgreSQL will just try it. Always estimate COUNT(*) of both sides in your head first.
Trap
Forgetting a join predicate, or cross joining two fact tables "to compare everything".
Q-SQL-035 What is a surrogate key?
Answer
A surrogate key is an artificial id, such as a sequence number or a hash, that does not come from the business. A natural key is a real-world unique field, such as email or SKU. Warehouses often use surrogate keys on dimensions.
Explanation
Interviewers ask this in modeling rounds. Example: customer_sk = 918203 while email may change. Facts store customer_sk so a renamed email does not rewrite history. In Hive and Spark, people often use monotonically_increasing_id, a database sequence, or a hash of the natural key. Hash keys are stable across reloads if the natural key is stable. Integer sequences are smaller in Parquet. Do not use a Spark monotonically_increasing_id as a durable key across jobs. It is not stable.
Trap
Treating email as an immutable key, or using a random id that changes every pipeline run.
Q-SQL-036 Fact table vs dimension table
Answer
A fact table stores events or measurements at a grain, such as one order line. A dimension table stores descriptive attributes, such as product name and category. Facts are long and numeric. Dimensions are wider and change more slowly.
Explanation
Interviewers want the grain in one sentence. "One row per order line per day" is a fact. "One row per product" is a dimension. You join them in a star schema. Degenerate dimensions such as invoice_number may live on the fact. In Spark lakes the same idea holds: keep facts append-only and dimensions upserted. If you cannot say the grain, the fact table will double-count.
Trap
Putting product color on the fact "because it is easier", then being unable to answer what one row means.
Q-SQL-037 What is partition pruning?
Answer
Partition pruning skips whole folders or files that cannot match the filter. If a table is partitioned by dt, then WHERE dt = '2024-06-01' should read only that day's partition. The query is faster because it reads less data.
Explanation
This is a core Hive/Spark interview topic. Pruning works when the filter is on the partition column in a form the planner can see. Wrapping the column, such as WHERE date_format(dt, 'yyyy') = '2024', often disables pruning. Dynamic pruning can skip partitions using values from a small dimension join. In production, partition by a column people actually filter, usually a date. Too many partitions (user_id) create tiny files. Too few (year only) skip little data. PostgreSQL has partition pruning on declarative partitions too.
Trap
Filtering on a transformed partition column, or partitioning by a unique key and calling the tiny-file mess "pruning".
Q-SQL-038 What are table statistics and ANALYZE?
Answer
Statistics are metadata about rows, values, and size. The optimizer uses them to pick joins and memory. ANALYZE (PostgreSQL) or ANALYZE TABLE / COMPUTE STATS (Hive) refreshes them. Spark uses table stats and file-level stats in Parquet and Delta.
Explanation
Interviewers ask this when a plan suddenly goes bad after a big load. Stale stats can make the engine broadcast a 200 GB table. PostgreSQL ANALYZE samples the table. Hive ANALYZE TABLE ... COMPUTE STATISTICS and column stats matter for CBO. Spark 3+ reads min/max and null counts from Parquet footers even without Hive metastore stats. In production, compute stats after large loads, and use EXPLAIN to see estimated rows. Do not collect column stats on hundreds of unused columns if it makes every ETL slower than the queries you save.
Trap
Ignoring stats entirely, or collecting stats so often that the stats job is more expensive than the queries.
Q-SQL-039 INNER JOIN vs LEFT JOIN
Answer
INNER JOIN keeps only rows that match in both tables. LEFT JOIN keeps every left row and fills right columns with NULL when there is no match. Use inner when you need a match. Use left when the left side must not disappear.
Explanation
Orders inner join customers drops orders with a missing customer id. Orders left join customers keeps those orders. Interviewers ask this daily. A common bug is LEFT JOIN then WHERE right.id IS NOT NULL, which turns it back into an inner join. Another bug is filtering the right table in WHERE instead of ON. In Spark, both joins shuffle or broadcast the same way. The difference is which rows survive. FULL OUTER JOIN keeps unmatched rows from both sides and is expensive.
Trap
Putting a right-table filter in WHERE after a LEFT JOIN and wondering where the unmatched left rows went.
Q-SQL-040 What does COALESCE do?
Answer
COALESCE returns the first argument that is not NULL. You use it to fill defaults and to combine columns. COALESCE(a, b, 0) is a if present, else b, else 0.
Explanation
Interviewers pair this with NULL questions. Example: COALESCE(mobile, email, 'unknown') as a contact key. Spark SQL also has NVL(a, b) with two arguments. PostgreSQL has COALESCE and NULLIF. COALESCE stops at the first non-null, so put cheap columns first. For NULL-safe equality, COALESCE is the wrong tool. Use IS NOT DISTINCT FROM or Spark <=>. In aggregates, SUM(COALESCE(amount, 0)) still returns NULL for no rows. Wrap the SUM if you need zero.
Trap
Using COALESCE to join on keys that can be NULL and accidentally matching many default values together.
Q-SQL-041 What is a covering index?
Answer
A covering index contains every column the query needs. The engine can answer from the index alone and skip the base table. It is a read speed trick for hot OLTP queries.
Explanation
If you SELECT email, status FROM users WHERE email = ? and a nonclustered index is (email) INCLUDE (status), the lookup is covered. Interviewers ask this in SQL Server and PostgreSQL index-only scan talks. PostgreSQL needs the visibility map for index-only scans. Hive/Spark covering is different: a column-oriented Parquet file already reads only selected columns. Z-ORDER on the filter columns plus column projection is the lakehouse version. Do not copy every column into every index. Writes get slower and the index becomes the table.
Trap
Creating a huge covering index for a warehouse fact table that is already columnar.
Q-SQL-042 What is MERGE or UPSERT?
Answer
MERGE (also called upsert) updates a row if the key exists and inserts it if it does not. Some engines can also delete when the source has no match. It is the standard way to load a changing dimension or a daily snapshot.
Explanation
Interviewers ask MERGE because almost every lakehouse pipeline needs it. Spark SQL and Delta: MERGE INTO target t USING source s ON t.id = s.id WHEN MATCHED THEN UPDATE ... WHEN NOT MATCHED THEN INSERT .... PostgreSQL often uses INSERT ... ON CONFLICT. Hive has MERGE on ACID tables. Production rules: the match key should be unique on both sides, or you get "multiple source rows matched" errors. Make the job idempotent so a rerun does not duplicate rows. Prefer merging only the partitions that changed.
Trap
Merging two datasets with duplicate keys, or running INSERT plus UPDATE as two non-transactional steps and leaving a hole after a crash.
Q-SQL-043 Find the top 2 products per category by spend
Answer
Add spend per product inside each category. Rank those totals with a window. Keep ranks 1 and 2. Say the grain first: one row per category and product.
Explanation
This is the classic top-N-per-group question. Interviewers want PARTITION BY plus a rank function, not a global ORDER BY LIMIT. DENSE_RANK keeps ties in the top 2. ROW_NUMBER forces exactly two rows and can drop a tied product. In Spark, the window shuffles by category. If one category is huge, that partition can skew. Pre-aggregate before you rank so the window is small.
Code
-- one row per category + product with total spend
WITH totals AS (
SELECT
category,
product,
SUM(spend) AS total_spend
FROM product_spend
GROUP BY category, product
)
-- rank inside the category, highest spend first
SELECT category, product, total_spend
FROM (
SELECT
totals.*,
DENSE_RANK() OVER (
PARTITION BY category
ORDER BY total_spend DESC
) AS rnk
FROM totals
) ranked
WHERE rnk <= 2;
What this code does
- First we sum spend so each product in a category is one row.
- Then we rank products inside each category.
- Then we keep rank 1 and 2.
- The result is the top-selling products per category, including ties if you use
DENSE_RANK.
Trap
Ranking the raw line items without aggregating, or using RANK and being surprised when ties return extra rows.
Q-SQL-044 Find the nth highest salary
Answer
Dense-rank salaries from high to low. Filter the rank equal to n. DENSE_RANK treats the same salary as one place. If n has no row, the query returns empty.
Explanation
This is the old "second highest salary" puzzle, generalized. Interviewers care whether ties count as one place. For "the 2nd distinct salary", use DENSE_RANK. For "the 2nd row after sorting", use ROW_NUMBER. Spark SQL has no LIMIT 1 OFFSET n that is safe with ties. PostgreSQL DISTINCT salary ORDER BY salary DESC OFFSET n-1 is another form. In production, wrap it as a parameter and decide the tie rule out loud.
Code
-- distinct salary ranks, 1 = highest
SELECT salary
FROM (
SELECT
salary,
DENSE_RANK() OVER (ORDER BY salary DESC) AS rnk
FROM employees
) t
-- change 2 to n
WHERE rnk = 2;
What this code does
- First we rank each salary among all employees, highest first.
- Equal salaries share the same dense rank.
- Then we keep only rank n (here 2).
- The result is the nth distinct salary, or no rows if fewer than n salaries exist.
Trap
Using MAX(salary) WHERE salary < MAX(salary) and stopping there, which does not generalize to n and ignores the tie rule.
Q-SQL-045 Running total of amount by user
Answer
Use SUM(amount) OVER ordered by time. Partition by the user so each user has their own running total. Default frames can include ties on the same timestamp, so add a unique id to the order.
Explanation
Running totals are the first window aggregate most interviews ask. They power balance sheets, cumulative signups, and stock of inventory. In Spark, ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW is the safe frame. RANGE treats equal timestamps as one group and can add extra rows. PostgreSQL is the same. On huge event tables, compute this in a batch job and store it, or use a streaming aggregation.
Code
SELECT
user_id,
order_id,
order_ts,
amount,
-- running sum inside each user, oldest order first
SUM(amount) OVER (
PARTITION BY user_id
ORDER BY order_ts, order_id
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_amount
FROM orders;
What this code does
- First we keep every order row.
- Then for each user we walk orders in time order.
- Each row's
running_amountis that order plus all earlier orders for the same user. - The result is a cumulative spend column next to the detail.
Trap
Omitting PARTITION BY user_id, so one customer gets the whole company's running total.
Q-SQL-046 Month-over-month revenue with LAG
Answer
Aggregate to one row per month. Use LAG to fetch last month's revenue. Subtract and divide to get the change. Handle month zero so you do not divide by NULL.
Explanation
LAG/LEAD questions test "look at the previous row" without a self join. Interviewers often want MoM or YoY percent change. Use a real month key such as date_trunc('month', dt) so missing months do not silently compare February to December. If a month has no sales, join a date spine first. Spark LAG(col, 1) is the previous row in the window. PostgreSQL is the same. In production, store the month grain table, then compute LAG on the small aggregate.
Code
-- one row per month
WITH monthly AS (
SELECT
date_trunc('month', order_ts) AS month_start,
SUM(amount) AS revenue
FROM orders
GROUP BY date_trunc('month', order_ts)
)
SELECT
month_start,
revenue,
-- previous month's revenue in time order
LAG(revenue, 1) OVER (ORDER BY month_start) AS prev_revenue,
-- MoM percent; NULL in the first month
ROUND(
100.0 * (revenue - LAG(revenue, 1) OVER (ORDER BY month_start))
/ NULLIF(LAG(revenue, 1) OVER (ORDER BY month_start), 0),
2
) AS mom_pct
FROM monthly;
What this code does
- First we sum revenue to month grain.
- Then
LAGpulls the prior month's revenue. - Then we compute percent change and guard a zero previous month with
NULLIF. - The result is each month, last month, and MoM percent.
Trap
Using LAG on raw order rows instead of monthly totals, or using datediff months that skips a missing month without a spine.
Q-SQL-047 Gaps and islands of consecutive login dates
Answer
Deduplicate to one row per user per date. Number the dates. Subtract the row number from the date. Consecutive dates share the same island key. Group by that key to get start, end, and streak length.
Explanation
This is the hardest common SQL pattern. Interviewers use it for streaks, uptime, and consecutive winning days. The trick: if dates go 1,2,3 and row numbers go 1,2,3, then date minus row number is constant. A gap changes the constant. Spark SQL: date_sub(login_date, rn). PostgreSQL: login_date - (rn || ' days')::interval or login_date - rn. Do not use this on timestamps with hours unless you first truncate to the grain you mean.
Code
-- one login day per user
WITH days AS (
SELECT DISTINCT user_id, login_date
FROM logins
),
numbered AS (
SELECT
user_id,
login_date,
-- sequence inside the user
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY login_date) AS rn
FROM days
),
islands AS (
SELECT
user_id,
login_date,
-- consecutive dates share this key
date_sub(login_date, rn) AS island_id
FROM numbered
)
SELECT
user_id,
MIN(login_date) AS streak_start,
MAX(login_date) AS streak_end,
COUNT(*) AS streak_days
FROM islands
GROUP BY user_id, island_id;
What this code does
- First we keep unique login dates per user.
- Then we number those dates in order.
- Then we subtract the number from the date to build an island id.
- Then we group and the result is each consecutive streak with start, end, and length.
Trap
Subtracting row numbers from timestamps that include time-of-day, so two logins on consecutive days do not share a key.
Q-SQL-048 Sessionize events with a 30-minute inactivity gap
Answer
Order events per user. Compare each event to the previous timestamp with LAG. If the gap is more than 30 minutes, start a new session. A running sum of those flags becomes the session id.
Explanation
Sessionization is how product analytics defines a visit. Interviewers want the inactivity rule stated: 30 minutes since the last event, not 30 minutes since session start. Spark: compare unix_timestamp values. PostgreSQL: EXTRACT(EPOCH FROM (event_ts - prev_ts)). On streaming data, do this with watermarks. In batch Spark, the window shuffle is by user_id. Skewed bots with millions of events need a special path.
Code
WITH ordered AS (
SELECT
user_id,
event_ts,
event_name,
-- previous event time for this user
LAG(event_ts) OVER (PARTITION BY user_id ORDER BY event_ts) AS prev_ts
FROM events
),
flagged AS (
SELECT
user_id,
event_ts,
event_name,
-- 1 means the gap is over 30 minutes (1800 seconds)
CASE
WHEN prev_ts IS NULL THEN 1
WHEN unix_timestamp(event_ts) - unix_timestamp(prev_ts) > 1800 THEN 1
ELSE 0
END AS new_session
FROM ordered
)
SELECT
user_id,
event_ts,
event_name,
-- running sum of flags is a session id inside the user
SUM(new_session) OVER (
PARTITION BY user_id
ORDER BY event_ts
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS session_id
FROM flagged;
What this code does
- First we find each user's previous event time.
- Then we mark a new session when the gap is over 30 minutes, or when it is the first event.
- Then we cumulative-sum those marks.
- The result is every event tagged with a session number per user.
Trap
Using datediff in days, which cannot see a 30-minute gap, or restarting the session from the first event instead of from inactivity.
Q-SQL-049 Deduplicate rows and keep the latest
Answer
Number rows with ROW_NUMBER partitioned by the business key and ordered by time descending. Keep rn = 1. That is the latest row per key. Add a unique tie-breaker so retries are stable.
Explanation
This is how you clean CDC dumps, app events, and slowly changing snapshots. Interviewers expect ROW_NUMBER, not SELECT DISTINCT, because DISTINCT does not know which duplicate to keep. Spark: QUALIFY ROW_NUMBER() ... = 1 works in Databricks SQL. PostgreSQL: DISTINCT ON (user_id) ... ORDER BY user_id, updated_at DESC. In production, the order must include a version or ingest id. Two rows with the same timestamp will flip if you only sort by time.
Code
SELECT user_id, email, status, updated_at
FROM (
SELECT
users.*,
-- 1 = latest row for this user
ROW_NUMBER() OVER (
PARTITION BY user_id
ORDER BY updated_at DESC, ingest_id DESC
) AS rn
FROM users
) t
WHERE rn = 1;
What this code does
- First we number each user's rows, newest first.
- A later
ingest_idwins when timestamps match. - Then we keep only
rn = 1. - The result is one current row per
user_id.
Trap
Using DISTINCT or GROUP BY user_id with MAX(updated_at) and then joining back without a tie-breaker, which can still return two rows.
Q-SQL-050 Employees who earn more than their manager
Answer
Self-join the employee table to the manager row. Compare salaries. Keep employees whose salary is greater than the manager salary. Use a left join if you must also show the CEO.
Explanation
This is the classic self-join. Interviewers want aliases and a clear join key: e.manager_id = m.emp_id. An inner join drops people with no manager. That is usually what you want here, because there is no manager salary to beat. In Spark, two copies of a large employee table still join on manager_id. For a deep org chart, this query is not enough. Use a recursive CTE.
Code
-- e is the employee, m is that employee's manager
SELECT
e.emp_id,
e.name AS emp_name,
e.salary AS emp_salary,
m.name AS manager_name,
m.salary AS manager_salary
FROM employees e
JOIN employees m
ON e.manager_id = m.emp_id
WHERE e.salary > m.salary;
What this code does
- First we join each employee to the row of their manager.
- Then we keep rows where the employee salary is higher.
- The result is the people who earn more than the person they report to.
Trap
Joining on e.emp_id = m.emp_id (the same person) or using > on names instead of salary.
Q-SQL-051 Market basket product pairs in the same order
Answer
Self-join order lines on order_id. Keep product_a < product_b so each pair is counted once. Count distinct orders that contain both. That is co-occurrence.
Explanation
Interviewers use this for "customers also bought". The < on product ids stops (milk, bread) and (bread, milk) from both appearing. Do not join on user only, or you mix different orders. In Spark this join can explode: an order with 20 items becomes 190 pairs. Filter to the top items first. PostgreSQL is the same SQL. For three-item sets the pattern grows fast. That is when people move to Spark MLlib FP-Growth.
Code
-- pairs of different products that share an order
SELECT
a.product_id AS product_a,
b.product_id AS product_b,
COUNT(DISTINCT a.order_id) AS orders_together
FROM order_items a
JOIN order_items b
ON a.order_id = b.order_id
AND a.product_id < b.product_id
GROUP BY a.product_id, b.product_id
ORDER BY orders_together DESC;
What this code does
- First we pair items that appear in the same order.
- The
<keeps each pair in one direction. - Then we count how many orders contain that pair.
- The result is a co-occurrence list you can sort for "bought together".
Trap
Forgetting product_a < product_b, so every pair is doubled, or joining two items from different orders of the same user.
Q-SQL-052 Pivot monthly amounts with CASE WHEN
Answer
Group by the row key. For each month, use SUM(CASE WHEN month = ... THEN amount ELSE 0 END). That turns rows into columns. There is no PIVOT in core Spark SQL, so CASE is the portable form.
Explanation
Conditional aggregation is how you build report grids. Interviewers want SUM(CASE...), not a mystery PIVOT keyword that differs by dialect. Spark and Hive: CASE. PostgreSQL: CASE or crosstab in tablefunc. Databricks has PIVOT. In production, a static list of months is fine for a 12-month report. For a dynamic set of columns, generate the SQL or keep the data long and pivot in the BI tool.
Code
SELECT
region,
-- each CASE becomes one month column
SUM(CASE WHEN month_num = 1 THEN amount ELSE 0 END) AS jan_amount,
SUM(CASE WHEN month_num = 2 THEN amount ELSE 0 END) AS feb_amount,
SUM(CASE WHEN month_num = 3 THEN amount ELSE 0 END) AS mar_amount
FROM monthly_sales
GROUP BY region;
What this code does
- First we keep one output row per region.
- Then each
CASEpicks the amount for one month and zero otherwise. SUMfolds those into a column.- The result is a wide report with January, February, and March amounts.
Trap
Using COUNT(CASE WHEN ... THEN 1 END) when you needed SUM(amount), or omitting ELSE 0 and then SUM returning NULL for a quiet month.
Q-SQL-053 Monthly cohort retention
Answer
Find each user's first activity month. That is the cohort. Count distinct users by cohort and months since first activity. Divide by the cohort size to get retention.
Explanation
Cohort retention is a senior analytics question. Interviewers want the grain: one user has one cohort month. Use MIN(activity_date) per user, not the event month of every event as a new cohort. Spark months_between or datediff / 30 is approximate. Better: date_trunc('month', ...) and then month index. PostgreSQL: AGE or (year*12+month) subtraction. In production, freeze the cohort on first paid order, not first page view, or marketing and finance will fight.
Code
WITH firsts AS (
-- one cohort month per user
SELECT
user_id,
date_trunc('month', MIN(event_date)) AS cohort_month
FROM events
GROUP BY user_id
),
activity AS (
SELECT DISTINCT
e.user_id,
date_trunc('month', e.event_date) AS activity_month
FROM events e
),
counts AS (
SELECT
f.cohort_month,
-- month index: 0 is the signup month
(YEAR(a.activity_month) * 12 + MONTH(a.activity_month))
- (YEAR(f.cohort_month) * 12 + MONTH(f.cohort_month)) AS month_n,
COUNT(DISTINCT a.user_id) AS active_users
FROM firsts f
JOIN activity a
ON f.user_id = a.user_id
GROUP BY
f.cohort_month,
(YEAR(a.activity_month) * 12 + MONTH(a.activity_month))
- (YEAR(f.cohort_month) * 12 + MONTH(f.cohort_month))
)
SELECT
cohort_month,
month_n,
active_users,
-- divide by the cohort size (month 0)
ROUND(
active_users * 1.0
/ MAX(CASE WHEN month_n = 0 THEN active_users END) OVER (PARTITION BY cohort_month),
4
) AS retention_rate
FROM counts;
What this code does
- First we compute each user's first month.
- Then we list months when the user was active.
- Then we group by cohort and month offset and count distinct users.
- Then we divide by the month-0 size so the result is a retention grid.
Trap
Putting every event month into the cohort key, so users are counted in many cohorts.
Q-SQL-054 Date spine and days between two dates
Answer
Build every date in a range, then left join facts onto it. That fills missing days with zero. datediff (Spark) returns whole days between two dates. PostgreSQL subtracts dates to get an integer.
Explanation
Interviewers ask a date spine when charts drop days with no sales. Without the spine, a line chart jumps from Friday to Monday. Spark SQL: explode(sequence(start, end, interval 1 day)). Hive: a calendar table, or posexplode on a range. PostgreSQL: generate_series(start, end, interval '1 day'). datediff(end, start) in Spark is end - start in days. In Hive, datediff is the same argument order as Spark. Store a real calendar table in production so weekends and holidays are flags, not formulas.
Code
-- Spark SQL date spine
WITH calendar AS (
SELECT explode(
sequence(to_date('2024-01-01'), to_date('2024-01-31'), interval 1 day)
) AS dt
),
daily AS (
SELECT to_date(order_ts) AS dt, SUM(amount) AS revenue
FROM orders
GROUP BY to_date(order_ts)
)
SELECT
c.dt,
COALESCE(d.revenue, 0) AS revenue,
-- days from the first calendar day
datediff(c.dt, to_date('2024-01-01')) AS days_from_start
FROM calendar c
LEFT JOIN daily d
ON c.dt = d.dt
ORDER BY c.dt;
What this code does
- First we generate every date in January 2024.
- Then we sum revenue by day from orders.
- Then we left join so quiet days stay in the result with 0.
datediffadds an integer day offset. In PostgreSQL usec.dt - DATE '2024-01-01'andgenerate_series.
Trap
Inner joining the calendar to sales, which removes the zero days you built the spine for.
Q-SQL-055 Recursive organization chart
Answer
Start with the top people (no manager). Recursively join employees whose manager is already in the result. Add 1 to the level each time. Stop at a max depth so a cycle cannot loop forever.
Explanation
Recursive CTEs walk trees and graphs. Interviewers use org charts, bill of materials, and folder paths. PostgreSQL: WITH RECURSIVE. Spark SQL supports recursive CTEs from 3.4. Older Hive needs a self-join loop in the job. Always set a depth cap. Real HR data has cycles from bad manager ids. In production, materialize the closure table (every ancestor pair) if many queries need "all reports of X".
Code
-- Spark 3.4+ / PostgreSQL recursive walk
WITH RECURSIVE org AS (
-- anchor: people at the top
SELECT
emp_id,
name,
manager_id,
1 AS lvl,
CAST(name AS STRING) AS path
FROM employees
WHERE manager_id IS NULL
UNION ALL
-- recursive step: people who report to someone already found
SELECT
e.emp_id,
e.name,
e.manager_id,
org.lvl + 1,
CONCAT(org.path, ' > ', e.name)
FROM employees e
JOIN org
ON e.manager_id = org.emp_id
WHERE org.lvl < 20
)
SELECT emp_id, name, manager_id, lvl, path
FROM org;
What this code does
- First we pick employees with no manager as level 1.
- Then we repeatedly add people who report to a person already in
org. lvl < 20stops a cycle.- The result is each employee, their depth, and a manager path.
Trap
Forgetting the depth cap, or using UNION instead of UNION ALL so the recursion is extra expensive.
Q-SQL-056 Median salary
Answer
Median is the middle value after sorting. In Spark use percentile_approx or percentile. In PostgreSQL use percentile_cont(0.5). You can also number the rows and pick the middle one or the average of two middle rows.
Explanation
Interviewers ask median because AVG is pulled by outliers. Finance and marketplace interviews expect this. percentile_approx is fast and slightly inexact. Exact median on a huge Spark table is expensive. The row-number method shows you understand the definition. For even counts, the usual continuous median averages the two middle values. Discrete median picks one of them. Say which one you mean.
Code
-- Spark approximate median (good on large tables)
SELECT percentile_approx(salary, 0.5) AS median_salary
FROM employees;
-- exact-enough method that works in more dialects
WITH ordered AS (
SELECT
salary,
ROW_NUMBER() OVER (ORDER BY salary) AS rn,
COUNT(*) OVER () AS n
FROM employees
WHERE salary IS NOT NULL
)
SELECT AVG(salary) AS median_salary
FROM ordered
WHERE rn IN (FLOOR((n + 1) / 2), CEIL((n + 1) / 2.0));
What this code does
- The first query asks Spark for the 50th percentile.
- The second query numbers salaries from low to high.
- It keeps the middle row, or the two middle rows when n is even.
AVGof those rows is the median. PostgreSQL:percentile_cont(0.5) WITHIN GROUP (ORDER BY salary).
Trap
Taking AVG(salary) and calling it the median, or forgetting to drop NULL salaries so the middle row is wrong.
Q-SQL-057 Funnel conversion by step
Answer
Map each event to a step number. For each user take the first time they hit each step. Count users who reached step 1, 2, 3 in order. Conversion is step n users divided by step 1 users.
Explanation
Funnels test event order, not only counts. Interviewers care whether a user who paid without viewing still counts. Strict funnels require timestamps in order: view < cart < pay. Loose funnels only check that the events exist. In Spark, aggregate to user-step first so you do not count extra page views. In production, define the window (same session vs 7 days) or marketing and product will quote different rates.
Code
-- first timestamp of each funnel step per user
WITH stepped AS (
SELECT
user_id,
CASE event_name
WHEN 'view_product' THEN 1
WHEN 'add_to_cart' THEN 2
WHEN 'purchase' THEN 3
END AS step,
MIN(event_ts) AS first_ts
FROM events
WHERE event_name IN ('view_product', 'add_to_cart', 'purchase')
GROUP BY user_id, event_name
),
-- keep viewers and attach later steps
ordered_users AS (
SELECT
s1.user_id,
s1.first_ts AS view_ts,
s2.first_ts AS cart_ts,
s3.first_ts AS pay_ts
FROM stepped s1
LEFT JOIN stepped s2
ON s1.user_id = s2.user_id AND s2.step = 2
LEFT JOIN stepped s3
ON s1.user_id = s3.user_id AND s3.step = 3
WHERE s1.step = 1
)
SELECT
-- only count a later step if it happened after the previous one
COUNT(view_ts) AS viewed,
COUNT(CASE WHEN cart_ts > view_ts THEN 1 END) AS carted,
COUNT(CASE WHEN pay_ts > cart_ts THEN 1 END) AS paid,
COUNT(CASE WHEN cart_ts > view_ts THEN 1 END) * 1.0 / COUNT(view_ts) AS view_to_cart,
COUNT(CASE WHEN pay_ts > cart_ts THEN 1 END) * 1.0
/ NULLIF(COUNT(CASE WHEN cart_ts > view_ts THEN 1 END), 0) AS cart_to_pay
FROM ordered_users;
What this code does
- First we take each user's first time at view, cart, and purchase.
- Then we keep users who viewed, and left join later steps.
- Then we count only steps that happened in time order.
- The result is funnel counts and conversion rates.
Trap
Counting events instead of users, so one bot with 10,000 views inflates the top of the funnel.
Q-SQL-058 Users with 3 consecutive login days
Answer
Build login islands per user. Count the length of each island. Keep users whose longest island is at least 3. This is gaps-and-islands applied to a streak filter.
Explanation
"Consecutive days" is not COUNT(*) >= 3 in a week. Tuesday, Thursday, Saturday is three logins with gaps. Interviewers want the island length. You can also use LEAD(login_date, 1) and LEAD(login_date, 2) and check they equal date plus 1 and plus 2. That form is easy to read for a fixed 3. The island form generalizes to any n. Use SELECT DISTINCT dates first so two logins on Monday do not break the math.
Code
-- unique login days so two logins on Monday count as one
WITH days AS (
SELECT DISTINCT user_id, login_date
FROM logins
),
islands AS (
SELECT
user_id,
login_date,
-- consecutive dates share this island key
date_sub(
login_date,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY login_date)
) AS island_id
FROM days
),
streaks AS (
SELECT user_id, COUNT(*) AS streak_days
FROM islands
GROUP BY user_id, island_id
)
SELECT DISTINCT user_id
FROM streaks
WHERE streak_days >= 3;
What this code does
- First we unique the login dates.
- Then we tag consecutive dates with an island id.
- Then we count days in each island.
- The result is users who had at least one 3-day streak.
Trap
Using COUNT(login_date) >= 3 per user without checking the dates are next to each other.
Q-SQL-059 Users whose second purchase is within 48 hours
Answer
Number purchases per user in time order. Keep purchase 1 and 2. Check that the second timestamp is within 48 hours of the first. Users with only one purchase drop out.
Explanation
This is a "time to second order" question. Interviewers use it for activation. LEAD on the first row, or a filter on ROW_NUMBER IN (1,2) plus a self join, both work. Spark: unix_timestamp(second) - unix_timestamp(first) <= 48 * 3600. datediff is whole days and is too coarse. PostgreSQL: second_ts <= first_ts + INTERVAL '48 hours'. Decide if a refunded order counts before you write SQL.
Code
-- number orders in time so rn=1 is first purchase
WITH numbered AS (
SELECT
user_id,
order_id,
order_ts,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY order_ts, order_id) AS rn
FROM orders
),
first_two AS (
SELECT
user_id,
MAX(CASE WHEN rn = 1 THEN order_ts END) AS first_ts,
MAX(CASE WHEN rn = 2 THEN order_ts END) AS second_ts
FROM numbered
WHERE rn <= 2
GROUP BY user_id
)
SELECT user_id, first_ts, second_ts
FROM first_two
-- 48 hours in seconds; datediff(days) is too coarse
WHERE second_ts IS NOT NULL
AND unix_timestamp(second_ts) - unix_timestamp(first_ts) <= 48 * 3600;
What this code does
- First we number each user's orders from earliest to latest.
- Then we pivot the first and second timestamps onto one row.
- Then we keep users whose second order exists and is within 48 hours.
- The result is the fast-repeat cohort.
Trap
Using datediff(second_ts, first_ts) <= 2, which treats 47 hours and 50 hours on nearby calendar days the same.
Q-SQL-060 Find overlapping intervals
Answer
Two ranges overlap when A starts before B ends and B starts before A ends. Self-join the interval table with those two inequalities. Exclude a row matching itself.
Explanation
This appears in hotel bookings, shift planning, and IP allocations. Interviewers want the overlap predicate, not BETWEEN on one timestamp. Touching endpoints (one ends at 10:00, the next starts at 10:00) may or may not count. Use < if they do not overlap, <= if they do. In Spark, a self join of a large booking table is heavy. Bucket by room_id first. PostgreSQL also has range types and &&.
Code
SELECT
a.booking_id AS booking_a,
b.booking_id AS booking_b,
a.room_id,
a.start_ts AS a_start,
a.end_ts AS a_end,
b.start_ts AS b_start,
b.end_ts AS b_end
FROM bookings a
JOIN bookings b
ON a.room_id = b.room_id
AND a.booking_id < b.booking_id
-- overlap: each starts before the other ends
AND a.start_ts < b.end_ts
AND b.start_ts < a.end_ts;
What this code does
- First we pair two different bookings on the same room.
booking_id <reports each pair once.- Then we keep pairs whose time ranges overlap.
- The result is the conflicting bookings.
Trap
Using a.start_ts BETWEEN b.start_ts AND b.end_ts only, which misses the case where A fully covers B.
Q-SQL-061 Rolling 7-day average
Answer
Fill missing days with a date spine so every user-day exists. Then average the last 7 rows with a window frame. ROWS BETWEEN 6 PRECEDING AND CURRENT ROW is 7 days only if the data has no gaps.
Explanation
Interviewers distinguish a 7-row window from 7 calendar days. If Sunday has no sales, a 7-row window quietly includes an older day. Production metrics almost always want calendar days. Build the spine, left join, COALESCE to 0, then the frame. Spark also has RANGE BETWEEN INTERVAL 6 DAYS PRECEDING AND CURRENT ROW on timestamp columns in newer versions. PostgreSQL supports RANGE with intervals. Say which one you are computing.
Code
WITH days AS (
SELECT explode(
sequence(DATE '2024-01-01', DATE '2024-01-31', INTERVAL 1 DAY)
) AS dt
),
users AS (
SELECT DISTINCT user_id FROM daily_spend
),
spine AS (
-- every user on every day
SELECT u.user_id, d.dt
FROM users u
CROSS JOIN days d
),
filled AS (
SELECT
s.user_id,
s.dt,
COALESCE(ds.amount, 0) AS amount
FROM spine s
LEFT JOIN daily_spend ds
ON s.user_id = ds.user_id
AND s.dt = ds.dt
)
SELECT
user_id,
dt,
amount,
AVG(amount) OVER (
PARTITION BY user_id
ORDER BY dt
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
) AS avg_7d
FROM filled;
What this code does
- First we build every date in the month and every user.
- Then we fill missing spend with 0.
- Then we average the current day and the six days before it.
- The result is a true 7-calendar-day average per user.
Trap
Applying ROWS BETWEEN 6 PRECEDING on a table that skipped zero days, so the window is not 7 calendar days.
Q-SQL-062 Second highest salary in the company
Answer
Take the distinct salaries, order them descending, and pick the second. DENSE_RANK = 2 is the clear form. If all salaries are equal, return empty or NULL, depending on what the interviewer wants.
Explanation
This is the LeetCode-style warmup. Interviewers still use it to see NULL handling. A popular follow-up: return NULL when there is no second salary. Wrap the rank query in a scalar subquery, or use MAX(salary) WHERE salary < (SELECT MAX(salary) FROM employees). The MAX form does not extend to the 5th salary. Prefer rank.
Code
-- highest salary among those below the overall max
SELECT MAX(salary) AS second_highest
FROM employees
WHERE salary < (SELECT MAX(salary) FROM employees);
What this code does
- First the subquery finds the highest salary.
- Then we keep only salaries below that.
- Then
MAXof what remains is the second highest. - The result is NULL when there is no second distinct salary.
Trap
Using LIMIT 1 OFFSET 1 without DISTINCT, which returns the second row even when it has the same salary as the first.
Q-SQL-063 Find duplicate emails
Answer
Group by email and keep groups with COUNT(*) > 1. That is the duplicate list. Join back to the base table if you need the row ids.
Explanation
This is a data-quality classic. Interviewers want GROUP BY ... HAVING, not a self join of the whole table. Decide whether emails are case-insensitive. In PostgreSQL you may GROUP BY LOWER(email). Hive and Spark string compares are case-sensitive by default. In production, duplicates on a "unique" column mean the load path is not idempotent.
Code
-- emails that appear on more than one row
SELECT email, COUNT(*) AS row_cnt
FROM users
GROUP BY email
HAVING COUNT(*) > 1;
What this code does
- First we group rows that share an email.
- Then
HAVINGkeeps only emails that appear more than once. - The result is the duplicate emails and how many rows they have.
Trap
Using WHERE COUNT(*) > 1, which is invalid, or grouping by user_id so you never see duplicates.
Q-SQL-064 Customers who never placed an order
Answer
This is an anti-join. Left join orders onto customers and keep rows where order_id IS NULL. NOT EXISTS and Spark LEFT ANTI JOIN are the same idea. Avoid NOT IN if customer_id can be NULL.
Explanation
Interviewers check that you do not inner join and then wonder where the zero-order customers went. On large Spark tables, LEFT ANTI JOIN is the clean plan. PostgreSQL NOT EXISTS usually plans well. NOT IN (SELECT customer_id FROM orders) is wrong if any order has a NULL customer_id, because the whole predicate becomes unknown.
Code
-- anti-join: keep customers with no matching order
SELECT c.customer_id, c.name
FROM customers c
LEFT JOIN orders o
ON c.customer_id = o.customer_id
WHERE o.order_id IS NULL;
What this code does
- First we keep every customer and matching orders.
- Customers with no order have NULL order columns.
- Then we keep those NULL matches.
- The result is customers who never ordered.
Trap
Using NOT IN (SELECT customer_id FROM orders) when customer_id is nullable and getting an empty result.
Q-SQL-065 Department with the highest average salary
Answer
Average salary by department. Rank those averages. Keep rank 1. DENSE_RANK keeps ties if two departments match. Do not mix the average with a detail WHERE on one employee.
Explanation
This tests group plus rank, not a nested MAX(AVG()) mess. Interviewers also accept ORDER BY avg_salary DESC LIMIT 1, but that hides ties. Spark: same SQL. If the department table has empty teams, decide whether AVG of no employees should appear. AVG ignores NULL salaries.
Code
-- average first, then rank departments
WITH dept_avg AS (
SELECT
dept_id,
AVG(salary) AS avg_salary
FROM employees
GROUP BY dept_id
)
SELECT dept_id, avg_salary
FROM (
SELECT
dept_id,
avg_salary,
DENSE_RANK() OVER (ORDER BY avg_salary DESC) AS rnk
FROM dept_avg
) t
WHERE rnk = 1;
What this code does
- First we compute average salary per department.
- Then we rank those averages, highest first.
- Then we keep the top rank.
- The result is the winning department, or more than one if they tie.
Trap
Writing SELECT dept_id, MAX(AVG(salary)) in one select list without a proper group, which is invalid in Spark SQL.
Q-SQL-066 Numbers that appear at least three times in a row
Answer
Use LAG and LEAD on the ordered rows. If the previous, current, and next values are equal, that value is a streak of three. You can also count island lengths and filter >= 3.
Explanation
This is LeetCode Consecutive Numbers, and it still shows up. Interviewers want you to define "in a row" on a given order key, usually id. Gaps in id may or may not break the streak. Ask. Spark and PostgreSQL both have LAG/LEAD. For longer streaks, gaps-and-islands is less error-prone than many LEADs.
Code
SELECT DISTINCT num AS ConsecutiveNums
FROM (
SELECT
num,
-- neighbors in id order
LAG(num, 1) OVER (ORDER BY id) AS prev_num,
LEAD(num, 1) OVER (ORDER BY id) AS next_num
FROM logs
) t
WHERE num = prev_num
AND num = next_num;
What this code does
- First we look at each row's neighbor values in id order.
- Then we keep rows whose neighbors equal the current number.
DISTINCTreturns the number once even if the streak is longer than three.- The result is values that appear three times in a row.
Trap
Grouping by num HAVING COUNT(*) >= 3 without checking the rows are adjacent.
Q-SQL-067 Days with a higher temperature than the previous day
Answer
Compare each day's temperature to the prior day with LAG, or self-join on record_date - 1. Keep rows where today is warmer. This is a simple row-over-row compare.
Explanation
LeetCode Rising Temperature. Interviewers use it to see LAG or a date self join. The self join fails if dates are missing, unless you only want "previous calendar day exists". LAG compares the previous row, which might be two calendar days ago. Say which rule you want. Spark date_sub(record_date, 1) is the previous calendar day.
Code
-- join today to the calendar-yesterday row
SELECT w.id
FROM weather w
JOIN weather p
ON p.record_date = date_sub(w.record_date, 1)
WHERE w.temperature > p.temperature;
What this code does
- First we join today to the row from yesterday's calendar date.
- Then we keep days that are warmer than that yesterday row.
- The result is the ids of rising-temperature days. Missing calendar days produce no match.
Trap
Using LAG and calling it "yesterday" when the table can skip dates.
Q-SQL-068 Rank scores without gaps
Answer
Use DENSE_RANK() OVER (ORDER BY score DESC). Ties share a rank. The next rank is the next integer, not a skip. RANK would skip, ROW_NUMBER would not tie.
Explanation
Interviewers name this "rank scores". They want the difference among the three rank functions in one query. Spark, Hive, and PostgreSQL all have DENSE_RANK. Do not implement it with a correlated COUNT of distinct higher scores unless they forbid windows.
Code
-- dense rank: ties share a number and the next value does not skip
SELECT
score,
DENSE_RANK() OVER (ORDER BY score DESC) AS rank
FROM scores
ORDER BY score DESC;
What this code does
- First we sort scores high to low inside the window.
- Equal scores share a dense rank.
- The next different score gets the next number with no gap.
- The result is each score with a competition rank.
Trap
Using RANK and producing 1, 1, 3 when the interviewer asked for 1, 1, 2.
Q-SQL-069 Top 3 salaries per department
Answer
Dense-rank salaries inside each department. Keep rnk <= 3. That includes ties for third place. If they want at most three rows, switch to ROW_NUMBER.
Explanation
This is the department top-N follow-up to company-wide nth salary. Interviewers watch the PARTITION BY dept_id. Join the department name after the rank if the name lives in another table. In Spark, ranking after a filter on active employees is cheaper than ranking everyone.
Code
SELECT d.name AS department, e.name AS employee, e.salary
FROM (
SELECT
emp_id,
name,
salary,
dept_id,
-- rank inside the department only
DENSE_RANK() OVER (
PARTITION BY dept_id
ORDER BY salary DESC
) AS rnk
FROM employees
) e
JOIN departments d
ON e.dept_id = d.dept_id
WHERE e.rnk <= 3;
What this code does
- First we rank employees inside their department by salary.
- Then we keep the top three ranks.
- Then we join department names.
- The result is each department's top earners, including salary ties.
Trap
Ranking without PARTITION BY, so the whole company shares one top 3.
Q-SQL-070 Managers with at least five direct reports
Answer
Group employees by manager_id. Keep managers whose count is at least 5. Join back to get the manager name. Direct reports only, not the whole tree.
Explanation
Interviewers check that you do not recurse. manager_id is one level. NULL manager ids are the CEO and should not form a group you care about. Spark: same group by. If they later ask for all descendants, that is the recursive CTE question.
Code
-- count direct reports, not the whole subtree
SELECT m.emp_id, m.name, COUNT(*) AS reports
FROM employees e
JOIN employees m
ON e.manager_id = m.emp_id
GROUP BY m.emp_id, m.name
HAVING COUNT(*) >= 5;
What this code does
- First we join each employee to their manager.
- Then we count rows per manager.
- Then
HAVINGkeeps managers with five or more directs. - The result is those managers and the report count.
Trap
Counting the whole subtree with a recursive CTE when the question only asked for direct reports.
Q-SQL-071 First login date for each player
Answer
MIN(event_date) grouped by player. That is the first login. You can also ROW_NUMBER by date and keep 1. The aggregate is simpler.
Explanation
Game-play analysis interviews start here, then add "logged in the next day" as a retention follow-up. MIN is enough for the first date. Use a window if you also need the whole first-day row. Spark and PostgreSQL are the same.
Code
-- earliest event date is first login
SELECT
player_id,
MIN(event_date) AS first_login
FROM activity
GROUP BY player_id;
What this code does
- First we group events by player.
- Then we take the earliest event date.
- The result is one first-login date per player.
Trap
Using ROW_NUMBER and then aggregating the same grain in a messier way, or taking MIN without GROUP BY player_id.
Q-SQL-072 Label tree nodes as root, inner, or leaf
Answer
A root has a NULL parent. A leaf is never a parent of anyone. An inner node has a parent and at least one child. Use two checks: parent column, and id in the set of parents.
Explanation
This is a simple tree question without recursion. Interviewers want CASE. Spark: id IN (SELECT p_id FROM tree WHERE p_id IS NOT NULL). Watch NULLs in the IN list. Filter p_id IS NOT NULL first. PostgreSQL is the same.
Code
SELECT
id,
CASE
WHEN p_id IS NULL THEN 'Root'
-- drop NULL parents from the IN list so NOT IN still works
WHEN id NOT IN (
SELECT p_id FROM tree WHERE p_id IS NOT NULL
) THEN 'Leaf'
ELSE 'Inner'
END AS type
FROM tree;
What this code does
- First we tag rows with no parent as Root.
- Then we tag ids that never appear as a parent as Leaf.
- Everyone else is Inner.
- The result is one label per node.
Trap
Using NOT IN (SELECT p_id FROM tree) when p_id has NULLs, which makes the leaf test fail.
Q-SQL-073 Percent of total revenue by product
Answer
Sum revenue per product. Divide by the window sum over all products. Multiply by 100. The window SUM(SUM(amount)) OVER () lets you do it in one query.
Explanation
Share-of-total is a daily dashboard question. Interviewers want a window, not a cross join to a one-row total, though both work. Spark needs * 1.0 or CAST or you get integer zero. PostgreSQL SUM(amount)::NUMERIC is safer. In production, define whether returns are negative amounts or a separate table.
Code
SELECT
product_id,
SUM(amount) AS revenue,
-- window sum of the grouped totals is company revenue
ROUND(
100.0 * SUM(amount) / SUM(SUM(amount)) OVER (),
2
) AS pct_of_total
FROM orders
GROUP BY product_id;
What this code does
- First we sum revenue per product.
- The window sum adds those product totals across the whole result.
- Then we divide to get a percent.
- The result is each product's share of company revenue.
Trap
Dividing two integers in Hive/Spark and getting 0, or using SUM(amount) OVER () on the raw rows while also grouping incorrectly.
Q-SQL-074 Year-over-year revenue growth
Answer
Aggregate to year. LAG the previous year's revenue. Compute (this - last) / last. You can also self-join on year = prior.year + 1. LAG is enough if there are no missing years.
Explanation
YoY is the yearly cousin of MoM. Interviewers may want same-month last year at month grain: join on month_num and year = year + 1, or LAG(..., 12) on a monthly spine. Missing months break LAG(..., 12). Use a spine. Spark date_trunc('year', dt) is the year key. PostgreSQL is the same.
Code
-- one row per calendar year
WITH yearly AS (
SELECT
YEAR(order_ts) AS yr,
SUM(amount) AS revenue
FROM orders
GROUP BY YEAR(order_ts)
)
SELECT
yr,
revenue,
LAG(revenue, 1) OVER (ORDER BY yr) AS prev_year_revenue,
ROUND(
100.0 * (revenue - LAG(revenue, 1) OVER (ORDER BY yr))
/ NULLIF(LAG(revenue, 1) OVER (ORDER BY yr), 0),
2
) AS yoy_pct
FROM yearly;
What this code does
- First we sum revenue by calendar year.
- Then
LAGfetches the previous year in the result. - Then we compute percent growth with a zero guard.
- The result is YoY change. If a year is missing, join a year spine first.
Trap
Using LAG(..., 1) on monthly data and calling it YoY.
Q-SQL-075 Users active in the last 7 days
Answer
Filter events to the last 7 days from a report date, then COUNT DISTINCT user_id. That is WAU if 7 days, DAU if 1 day. Be explicit about the report date, not current_date in a backfill.
Explanation
DAU/WAU/MAU are standard product metrics. Interviewers want a parameter for "as of" date so the job is deterministic. Spark: event_date BETWEEN date_sub(report_date, 6) AND report_date for 7 inclusive days. Off-by-one errors are common. In production, pre-aggregate unique users per day, then union the last 7 day keys. Distinct on raw events every hour is expensive.
Code
-- inclusive 7 days ending 2024-06-30
SELECT COUNT(DISTINCT user_id) AS active_7d
FROM events
WHERE event_date BETWEEN date_sub(DATE '2024-06-30', 6) AND DATE '2024-06-30';
What this code does
- First we keep events on 24 Jun through 30 Jun inclusive.
- Then we count unique users.
- The result is 7-day active users as of 30 Jun 2024.
Trap
Using date_sub(..., 7) with BETWEEN and accidentally making an 8-day window, or using NOW() in a batch that must replay history.
Q-SQL-076 Users who churned after 90 days of no order
Answer
Take each user's last order date. If it is more than 90 days before the report date, they have churned. Users with no orders are a separate bucket unless you treat them as never activated.
Explanation
Churn definitions differ: last order, last login, or subscription cancel. Interviewers want you to pick one and write it. Spark datediff(report_date, last_order) > 90. Inclusive vs exclusive matters for finance. In production, churn is often a state table you merge daily, not a one-off filter, because users can reactivate.
Code
-- last activity, not first order
WITH last_order AS (
SELECT user_id, MAX(order_ts) AS last_ts
FROM orders
GROUP BY user_id
)
SELECT
user_id,
last_ts,
datediff(DATE '2024-06-30', to_date(last_ts)) AS days_idle
FROM last_order
WHERE datediff(DATE '2024-06-30', to_date(last_ts)) > 90;
What this code does
- First we find each user's latest order time.
- Then we measure whole days from that order to the report date.
- Then we keep users idle more than 90 days.
- The result is the churned-by-inactivity list.
Trap
Using MIN(order_ts) (first order) instead of last activity, or churning users who ordered today because of a timezone shift.
Q-SQL-077 Fill missing dates with zero sales
Answer
Cross a calendar spine with the entities you care about. Left join the facts. COALESCE measures to 0. This is the date-spine pattern used in charts and rolling windows.
Explanation
Interviewers repeat this because it shows up inside rolling averages, retention, and inventory. Spark sequence plus explode builds the calendar. PostgreSQL generate_series. Hive teams usually keep a dim_date table, which is the production answer. Do not generate 20 years of dates in every query if a table already exists.
Code
-- Spark date spine for one week
WITH calendar AS (
SELECT explode(sequence(DATE '2024-01-01', DATE '2024-01-07', INTERVAL 1 DAY)) AS dt
),
stores AS (
SELECT DISTINCT store_id FROM sales
)
SELECT
s.store_id,
c.dt,
-- quiet days become 0, not missing
COALESCE(SUM(f.amount), 0) AS amount
FROM stores s
CROSS JOIN calendar c
LEFT JOIN sales f
ON f.store_id = s.store_id
AND to_date(f.sale_ts) = c.dt
GROUP BY s.store_id, c.dt;
What this code does
- First we list days and stores.
- Then we cross them so every store-day exists.
- Then we left join sales and sum, filling NULL with 0.
- The result is a dense store-by-day series.
Trap
Aggregating after the left join without grouping the spine keys, which duplicates calendar rows.
Q-SQL-078 Unpivot month columns into rows
Answer
UNION ALL each month column as its own row, or use STACK / UNPIVOT where it exists. Long format is easier to aggregate later. Wide format is for exports.
Explanation
The reverse of CASE pivot. Interviewers may give jan_amt, feb_amt, mar_amt and want a month column. Spark SQL: STACK(3, '2024-01', jan_amt, '2024-02', feb_amt, '2024-03', mar_amt). PostgreSQL: UNION ALL or jsonb tricks. UNION ALL is the portable answer. In production, stop storing months as columns.
Code
-- stack each month column into a long row
SELECT store_id, month_start, amount
FROM (
SELECT store_id, DATE '2024-01-01' AS month_start, jan_amt AS amount FROM wide_sales
UNION ALL
SELECT store_id, DATE '2024-02-01', feb_amt FROM wide_sales
UNION ALL
SELECT store_id, DATE '2024-03-01', mar_amt FROM wide_sales
) t
WHERE amount IS NOT NULL;
What this code does
- First we take each month column as its own SELECT.
- Then
UNION ALLstacks them into a long table. - Then we drop NULL amounts if that month had no value.
- The result is one row per store per month.
Trap
Using UNION (distinct) on a large unpivot and paying a shuffle you do not need.
Q-SQL-079 Compare two tables and find mismatches
Answer
Full outer join on the key. Compare columns with NULL-safe equality. Keep rows where any column differs or a key is missing on one side. EXCEPT only shows missing keys, not changed values, unless you compare whole rows.
Explanation
Reconciliation is a data-engineer interview favorite. Interviewers want a checksum story for billions of rows: hash the payload, compare hashes, then drill into diffs. Spark: a.col <=> b.col is NULL-safe. PostgreSQL: IS NOT DISTINCT FROM. EXCEPT / MINUS is fine for small dimension dumps. In production, compare partition by partition.
Code
SELECT
COALESCE(a.id, b.id) AS id,
CASE
WHEN a.id IS NULL THEN 'missing_in_a'
WHEN b.id IS NULL THEN 'missing_in_b'
ELSE 'value_mismatch'
END AS diff_type,
a.amount AS amount_a,
b.amount AS amount_b
FROM table_a a
FULL OUTER JOIN table_b b
ON a.id = b.id
-- <=> is Spark NULL-safe equals; PostgreSQL: IS NOT DISTINCT FROM
WHERE a.id IS NULL
OR b.id IS NULL
OR NOT (a.amount <=> b.amount);
What this code does
- First we full-join on the business key.
- Then we label missing keys versus changed amounts.
<=>treats NULL amount as equal to NULL.- The result is the diff set. PostgreSQL: use
IS NOT DISTINCT FROMinstead of<=>.
Trap
Using a.amount != b.amount, which is unknown when either amount is NULL, so the mismatch is dropped.
Q-SQL-080 Delete duplicate rows and keep one
Answer
Identify duplicates with ROW_NUMBER. Delete where rn > 1. In Spark you usually rewrite the table with INSERT OVERWRITE or MERGE keeping rn = 1, because row-level DELETE may not exist on files.
Explanation
Interviewers want a plan that is safe to rerun. OLTP: DELETE FROM t WHERE id IN (SELECT id FROM ranked WHERE rn > 1). Lakehouse: filter rn = 1 and replace the partition. Never SELECT DISTINCT * if two rows differ only on a junk column you still need to choose. Pick an order: latest updated_at, then largest id.
Code
-- Spark / Hive rewrite: keep one row per email
INSERT OVERWRITE TABLE users
SELECT email, user_id, updated_at, status
FROM (
SELECT
users.*,
ROW_NUMBER() OVER (
PARTITION BY email
ORDER BY updated_at DESC, user_id DESC
) AS rn
FROM users
) t
WHERE rn = 1;
What this code does
- First we number duplicate emails, latest row first.
- Then we keep
rn = 1. - Then we overwrite the table with the clean set.
- The result is unique emails. PostgreSQL would
DELETEusingctidor a uniqueidinstead of overwrite.
Trap
Deleting all rows of a duplicate group, including the one you meant to keep.
Q-SQL-081 Latest status as of a given date
Answer
This is an as-of join. Keep history rows whose valid_from is on or before the as-of date, and whose valid_to is after it (or NULL). If you store only valid_from, take ROW_NUMBER of rows with valid_from <= as_of ordered descending and keep 1.
Explanation
SCD Type 2 interviews always end here. Interviewers want the grain: one row per entity at that date. Overlapping valid_from/valid_to ranges are a data bug. Spark as-of joins exist in some APIs; in SQL the window method is portable. In production, constrain the scan to partitions near that date.
Code
SELECT customer_id, status, valid_from, valid_to
FROM (
SELECT
customer_id,
status,
valid_from,
valid_to,
-- latest start wins if ranges overlap by mistake
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY valid_from DESC
) AS rn
FROM dim_customer
-- open interval covering the as-of date
WHERE valid_from <= DATE '2024-03-15'
AND (valid_to IS NULL OR valid_to > DATE '2024-03-15')
) t
WHERE rn = 1;
What this code does
- First we keep history rows that cover 15 Mar 2024.
- Then we rank them latest
valid_fromfirst in case of bad overlaps. - Then we keep one row per customer.
- The result is the customer status as of that date.
Trap
Filtering valid_from = as_of_date, which misses rows that started earlier and are still open.
Q-SQL-082 Last-click attribution
Answer
Explanation
Attribution is a growth-analytics favorite. Last click is the simple rule. First click reverses the order. Linear split is a later follow-up. Interviewers want the time filter touch_ts <= convert_ts and often a lookback such as 7 days. Spark: join touches to conversions on user_id, then rank. In production, define whether paid and organic compete, and whether a touch is session-level or event-level.
Code
WITH candidates AS (
SELECT
c.conversion_id,
c.user_id,
c.convert_ts,
t.channel,
t.touch_ts,
-- latest touch before the conversion
ROW_NUMBER() OVER (
PARTITION BY c.conversion_id
ORDER BY t.touch_ts DESC
) AS rn
FROM conversions c
JOIN touches t
ON t.user_id = c.user_id
AND t.touch_ts <= c.convert_ts
AND t.touch_ts >= c.convert_ts - INTERVAL 7 DAYS
)
SELECT conversion_id, user_id, convert_ts, channel AS last_click_channel
FROM candidates
WHERE rn = 1;
What this code does
- First we find touches in the 7 days before each conversion.
- Then we rank those touches latest first.
- Then we keep the last touch.
- The result is one channel per conversion. Conversions with no touch need a left join if you want a 'direct' bucket.
Trap
Taking the last touch in the whole history, including after the purchase, or attributing one touch to every conversion of that user.
Q-SQL-083 Put customers into revenue deciles with NTILE
Answer
Sum revenue per customer. NTILE(10) over that sum descending puts them in 10 buckets. Bucket 1 is the top 10 percent. Ties can make buckets slightly uneven.
Explanation
NTILE is how interviews ask Pareto and VIP segments. Interviewers know NTILE does not guarantee equal sums, only equal counts (almost). For true percentile ranks use PERCENT_RANK or CUME_DIST. Spark and PostgreSQL both have NTILE. In production, compute deciles on a fixed snapshot date so a customer does not jump buckets every hour.
Code
WITH spend AS (
-- grain is customer, not order
SELECT user_id, SUM(amount) AS revenue
FROM orders
GROUP BY user_id
)
SELECT
user_id,
revenue,
NTILE(10) OVER (ORDER BY revenue DESC) AS decile
FROM spend;
What this code does
- First we total revenue per customer.
- Then we split the ranked list into 10 buckets.
- Decile 1 is the highest spenders.
- The result is a VIP ranking you can filter as
decile = 1.
Trap
Applying NTILE on raw order rows, so a customer with 50 small orders fills many tiles.
Q-SQL-084 Repeat purchase rate
Answer
Count users with at least one order, and users with at least two. Divide. That is repeat rate. You can also use COUNT(DISTINCT order_id) >= 2 per user.
Explanation
Simple metric, easy to mess up the grain. Interviewers want users, not orders, in the denominator. A user with 10 orders is one repeat user. Time-box it: "repeat within 30 days of first order" is a different question (see second purchase within 48 hours). Spark COUNT(*) on a user-level flag table is cheap after the aggregate.
Code
WITH user_orders AS (
SELECT user_id, COUNT(DISTINCT order_id) AS order_cnt
FROM orders
GROUP BY user_id
)
SELECT
COUNT(*) AS buyers,
-- users, not orders, in both numerator and denominator
SUM(CASE WHEN order_cnt >= 2 THEN 1 ELSE 0 END) AS repeat_buyers,
SUM(CASE WHEN order_cnt >= 2 THEN 1 ELSE 0 END) * 1.0 / COUNT(*) AS repeat_rate
FROM user_orders;
What this code does
- First we count orders per user.
- Then we count how many users bought at least once and at least twice.
- Then we divide.
- The result is repeat purchase rate among buyers.
Trap
Dividing repeat orders by total orders, which is a different metric and looks inflated.
Q-SQL-085 Histogram of order amounts
Answer
Bucket amounts with CASE or WIDTH_BUCKET. Group by the bucket and count. That is a histogram. Keep the bucket bounds in the result so a chart can plot them.
Explanation
Interviewers use this for "distribution of order value". Fixed-width buckets are easy to explain. Percentile buckets (NTILE) are better when the tail is long. Spark: CASE or width_bucket is not always present; CASE is portable. PostgreSQL has width_bucket. In production, cap the last bucket as "500+".
Code
SELECT
-- prefix keeps buckets in amount order
CASE
WHEN amount < 20 THEN '00_0_20'
WHEN amount < 50 THEN '01_20_50'
WHEN amount < 100 THEN '02_50_100'
ELSE '03_100_plus'
END AS bucket,
COUNT(*) AS orders,
SUM(amount) AS revenue
FROM orders
GROUP BY
CASE
WHEN amount < 20 THEN '00_0_20'
WHEN amount < 50 THEN '01_20_50'
WHEN amount < 100 THEN '02_50_100'
ELSE '03_100_plus'
END
ORDER BY bucket;
What this code does
- First we map each order to a named amount bucket.
- Then we count orders and sum revenue per bucket.
- The prefix on the name keeps the sort order.
- The result is a histogram table.
Trap
Using overlapping BETWEEN ranges so 50 is counted twice, or putting NULLs into the last bucket without saying so.
Q-SQL-086 Weighted average price
Answer
Weighted average is SUM(price * qty) / SUM(qty). Do not average the prices of rows. A row with qty 100 must count more than a row with qty 1.
Explanation
This is a favorite "gotcha" aggregate. Interviewers want to see the formula, not AVG(price). NULL qty should be excluded or coalesced. Spark and PostgreSQL: same. If qty can be zero, NULLIF(SUM(qty), 0) avoids divide by zero. In production this is VWAP for trades and average selling price for retail.
Code
SELECT
product_id,
-- quantity-weighted price, not AVG(price)
SUM(price * qty) * 1.0 / NULLIF(SUM(qty), 0) AS weighted_avg_price
FROM order_items
GROUP BY product_id;
What this code does
- First we multiply price by quantity on each line.
- Then we add those extensions and add quantities.
- Then we divide to get a quantity-weighted price.
- The result is one ASP per product.
Trap
Writing AVG(price) and calling it the selling price.
Q-SQL-087 Explode array tags and count them
Answer
Turn the array into rows, then GROUP BY the tag. In Spark SQL use EXPLODE. In PostgreSQL use unnest. Filter null or empty tags after the explode.
Explanation
Nested data is a Hive/Spark interview topic. Interviewers give ARRAY of tags on an event. LATERAL VIEW EXPLODE is the Hive form. Spark also allows SELECT explode(tags) AS tag. Do not explode before you filter the parent table if you can avoid it. In production, exploding a huge array of unique ids can blow up shuffle size.
Code
-- Spark SQL
SELECT tag, COUNT(*) AS events
FROM (
SELECT explode(tags) AS tag
FROM events
WHERE tags IS NOT NULL
) t
WHERE tag IS NOT NULL
GROUP BY tag
ORDER BY events DESC;
-- Hive form:
-- SELECT tag, COUNT(*) AS events
-- FROM events
-- LATERAL VIEW EXPLODE(tags) e AS tag
-- GROUP BY tag;
What this code does
- First we skip rows with a NULL array.
- Then we turn each array into one row per tag.
- Then we count events per tag.
- The result is a tag frequency table. PostgreSQL:
FROM events, unnest(tags) AS tag.
Trap
Exploding and then counting users with COUNT(*) instead of COUNT(DISTINCT user_id) when the question asked unique users.
Q-SQL-088 MERGE a staging table into a target
Answer
MERGE on the business key. Update when the key matches. Insert when it does not. Make sure the source has one row per key. Reruns should land on the same target rows.
Explanation
This is the production upsert. Delta Lake, Iceberg, Hive ACID, and Snowflake all have MERGE. PostgreSQL: INSERT ... ON CONFLICT. Interviewers want the ON clause to match the grain. Duplicate keys in the source fail or duplicate updates. In Spark, merge only the partitions that changed (dt = ...) so you do not rewrite the whole table.
Code
MERGE INTO dim_customer t
USING (
-- source must be unique on customer_id
SELECT
customer_id,
email,
status,
updated_at
FROM stg_customer
) s
ON t.customer_id = s.customer_id
WHEN MATCHED AND t.updated_at < s.updated_at THEN
UPDATE SET
t.email = s.email,
t.status = s.status,
t.updated_at = s.updated_at
WHEN NOT MATCHED THEN
INSERT (customer_id, email, status, updated_at)
VALUES (s.customer_id, s.email, s.status, s.updated_at);
What this code does
- First we pick a unique staging set.
- Then we match existing customers on
customer_id. - We update only if the staging row is newer.
- We insert customers that are not in the target. PostgreSQL would use
ON CONFLICT (customer_id) DO UPDATE.
Trap
Merging a source with duplicate keys, or updating even when the target is already newer so a late rerun rewinds data.
Q-SQL-089 Generate a numbers or calendar sequence
Answer
In Spark, sequence(start, end) plus explode builds dates or integers. In PostgreSQL, generate_series. Recursive CTEs can also count, but a sequence function is clearer and safer.
Explanation
Interviewers ask this when there is no dim_date. It is the engine for spines, histograms, and filling ids. Recursive generation must cap the depth. Spark sequence is the right tool. Hive 2 often needs a dummy table of numbers. In production, materialize a calendar table once.
Code
-- Spark integers 1..10
SELECT explode(sequence(1, 10)) AS n;
-- Spark dates
SELECT explode(sequence(DATE '2024-01-01', DATE '2024-01-07', INTERVAL 1 DAY)) AS dt;
-- PostgreSQL:
-- SELECT generate_series(1, 10) AS n;
-- SELECT generate_series(DATE '2024-01-01', DATE '2024-01-07', INTERVAL '1 day') AS dt;
What this code does
sequencebuilds an array from start to end.explodeturns that array into rows.- The date form steps by one day.
- The result is a small tally or calendar you can join.
Trap
A recursive CTE with no stop condition, or generating 100 years of days inside a dashboard query every time.
Q-SQL-090 First and last event per user
Answer
Use MIN and MAX on the timestamp if you only need the times. Use FIRST_VALUE / LAST_VALUE or two ROW_NUMBERs if you need the whole event row. LAST_VALUE needs a frame that includes the last row.
Explanation
Interviewers catch the LAST_VALUE frame bug. Default frame is RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, so LAST_VALUE is often the current row, not the last in the partition. Fix: ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING, or rank twice. Spark and PostgreSQL share this trap. For only timestamps, MIN/MAX is cleaner.
Code
SELECT
user_id,
MIN(event_ts) AS first_ts,
MAX(event_ts) AS last_ts,
-- first event name in time order
MAX(CASE WHEN rn_asc = 1 THEN event_name END) AS first_event,
MAX(CASE WHEN rn_desc = 1 THEN event_name END) AS last_event
FROM (
SELECT
user_id,
event_ts,
event_name,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY event_ts, event_id) AS rn_asc,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY event_ts DESC, event_id DESC) AS rn_desc
FROM events
) t
GROUP BY user_id;
What this code does
- First we number events from the start and from the end.
- Then we pick the names where those numbers are 1.
MIN/MAXgive the times.- The result is each user's first and last event.
Trap
Using LAST_VALUE(event_name) OVER (PARTITION BY user_id ORDER BY event_ts) with the default frame and thinking it is the last event in the session.
Q-SQL-091 Customers who bought every product
Answer
Count distinct products each customer bought. Compare that count to the count of products in the catalog. Keep customers where the two numbers are equal. This is relational division.
Explanation
Interviewers like this because JOIN plus COUNT DISTINCT is cleaner than a double NOT EXISTS. It assumes the catalog table is the full set. If products can be inactive, count only active products. Spark: same SQL. Watch NULLs in product_id. Distinct will skip them.
Code
SELECT o.customer_id
FROM orders o
JOIN order_items i
ON o.order_id = i.order_id
GROUP BY o.customer_id
-- distinct products bought equals catalog size
HAVING COUNT(DISTINCT i.product_id) = (SELECT COUNT(*) FROM products);
What this code does
- First we list products each customer bought.
- Then we count distinct product ids per customer.
- Then we compare to the size of the product table.
- The result is customers who covered the whole catalog.
Trap
Comparing to COUNT(*) of order_items instead of products, or using COUNT(product_id) without DISTINCT.
Q-SQL-092 Cap outliers using a percentile
Answer
Compute a high percentile, such as the 99th. LEAST(amount, p99) caps the value. Use that capped value in AVG so one huge order does not dominate.
Explanation
Winsorizing is a data-science SQL question that now appears in DE loops. Interviewers want a window or a cross join of the percentile, not a manual WHERE amount < 10000 with a guessed number. Spark percentile_approx(amount, 0.99) is the scalable form. PostgreSQL percentile_cont(0.99). In production, compute the cap on a prior window so today's fraud spike does not set its own cap.
Code
WITH caps AS (
-- Spark approximate 99th percentile
SELECT percentile_approx(amount, 0.99) AS p99
FROM orders
)
SELECT
ROUND(AVG(LEAST(o.amount, c.p99)), 2) AS avg_capped,
ROUND(AVG(o.amount), 2) AS avg_raw
FROM orders o
CROSS JOIN caps c;
What this code does
- First we estimate the 99th percentile of amount.
- Then we cap each order at that value with
LEAST. - Then we average both capped and raw amounts.
- The result shows how much the tail moved the mean. PostgreSQL:
percentile_cont(0.99) WITHIN GROUP (ORDER BY amount).
Trap
Filtering WHERE amount < p99 and dropping the rows, which is trimming, not capping, and changes order count.
Q-SQL-093 Share of users who converted
Answer
Count distinct users who did the conversion event. Divide by distinct users who could have converted. That is a user conversion rate, not an event rate.
Explanation
Interviewers check grain again. Page-view events in the denominator make conversion look tiny. Use the eligible population: visitors, signups, or trial starts. Spark: COUNT(DISTINCT CASE WHEN event_name = 'purchase' THEN user_id END). In production, one user with many purchases still counts once.
Code
SELECT
COUNT(DISTINCT user_id) AS visitors,
-- users who purchased, counted once
COUNT(DISTINCT CASE WHEN event_name = 'purchase' THEN user_id END) AS converters,
COUNT(DISTINCT CASE WHEN event_name = 'purchase' THEN user_id END) * 1.0
/ COUNT(DISTINCT user_id) AS conversion_rate
FROM events;
What this code does
- First we count unique users in the event stream.
- Then we count unique users who purchased.
- Then we divide.
- The result is user conversion rate.
Trap
Using COUNT(*) of purchase events over COUNT(*) of all events.
Q-SQL-094 Same-day repeat orders
Answer
Count orders per user per calendar date. Keep user-days with at least two orders. That is same-day repeat. If they want within 24 hours across midnight, compare timestamps instead of dates.
Explanation
This is a lighter version of the 48-hour second-purchase question. Interviewers want you to ask: calendar day or 24-hour window? Time zones change the date. Spark to_date(order_ts, 'UTC') should be explicit. In production, store a local_date from the user's zone if the metric is "same day".
Code
SELECT user_id, order_date, order_cnt
FROM (
SELECT
user_id,
to_date(order_ts) AS order_date,
COUNT(*) AS order_cnt
FROM orders
GROUP BY user_id, to_date(order_ts)
) t
-- two or more orders on the same calendar date
WHERE order_cnt >= 2;
What this code does
- First we count orders for each user on each calendar date.
- Then we keep user-days with two or more orders.
- The result is same-day repeat activity.
Trap
Using datediff = 0 on timestamps in different time zones, or counting items instead of orders.
Q-SQL-095 Daily cumulative unique users
Answer
True running distinct is hard in SQL. Approximate it with HyperLogLog (approx_count_distinct in a mergeable way) or compute exact distinct with a expanding self-join / window of user-day firsts. Exact on huge data is expensive.
Explanation
Interviewers at senior level ask this to see if you know it is not SUM(COUNT DISTINCT) OVER. You cannot add daily distincts. A user active two days would double-count. The exact method: tag each user's first seen date, then count users whose first date is on or before today. That is cumulative new users, which equals cumulative unique if you start from the beginning. Spark approx_count_distinct is not additive either unless you use HLL sketches (Databricks hll_sketch, Presto/Trino approx_set).
Code
WITH first_seen AS (
-- each user contributes to the cumulative from their first day
SELECT user_id, MIN(to_date(event_ts)) AS first_date
FROM events
GROUP BY user_id
),
calendar AS (
SELECT explode(sequence(
(SELECT MIN(first_date) FROM first_seen),
(SELECT MAX(first_date) FROM first_seen),
INTERVAL 1 DAY
)) AS dt
)
SELECT
c.dt,
COUNT(f.user_id) AS cumulative_unique_users
FROM calendar c
LEFT JOIN first_seen f
ON f.first_date <= c.dt
GROUP BY c.dt
ORDER BY c.dt;
What this code does
- First we find each user's first seen date.
- Then we build every date in the range.
- Then we count users whose first date is on or before that day.
- The result is exact cumulative unique users from the start of the data.
Trap
Writing SUM(daily_distinct) OVER (ORDER BY dt) and calling it cumulative unique.
Q-SQL-096 Find the mode of a column
Answer
Mode is the most frequent value. Group, count, and take the top rank. If two values tie, say whether you return both or one. Spark has mode() in some versions; the group-by form is portable.
Explanation
Interviews use mode for "most common status" and "most common error code". AVG and median do not apply to categories. PostgreSQL has MODE() WITHIN GROUP (ORDER BY col) but it picks one value on a tie. The rank query makes the tie visible. In production, ignore NULL unless NULL is a real category you care about.
Code
SELECT value AS mode_value, freq
FROM (
SELECT
status AS value,
COUNT(*) AS freq,
-- rank 1 is the most common value; ties stay visible
DENSE_RANK() OVER (ORDER BY COUNT(*) DESC) AS rnk
FROM tickets
WHERE status IS NOT NULL
GROUP BY status
) t
WHERE rnk = 1;
What this code does
- First we count rows per status.
- Then we rank those frequencies.
- Then we keep the highest frequency.
- The result is the mode, or more than one status if they tie.
Trap
Using AVG or MAX(status) and calling it the most common value.
Q-SQL-097 Top N per group with QUALIFY
Answer
QUALIFY filters window functions without a subquery. It is Spark SQL (Databricks) and Snowflake syntax. PostgreSQL does not have QUALIFY. Use a subquery there. The logic is still rank then keep <= N.
Explanation
Interviewers working on Databricks like QUALIFY because it is short. It is the same plan as a subquery. You still choose ROW_NUMBER vs DENSE_RANK. Do not put QUALIFY in a dialect-agnostic homework unless you mention the fallback. Production Spark jobs on open-source Spark 3 may not parse QUALIFY unless you are on Databricks SQL.
Code
-- Databricks SQL / Snowflake
SELECT category, product, total_spend
FROM (
SELECT category, product, SUM(spend) AS total_spend
FROM product_spend
GROUP BY category, product
) t
QUALIFY ROW_NUMBER() OVER (
PARTITION BY category
ORDER BY total_spend DESC
) <= 2;
-- PostgreSQL / core Spark fallback is the subquery in Q-SQL-043
What this code does
- First we aggregate spend per product.
- Then
QUALIFYcomputes a row number inside each category. - Then it keeps the first two rows per category.
- The result is top 2 products. In PostgreSQL wrap the window in a subquery and filter
WHERE rn <= 2.
Trap
Writing WHERE ROW_NUMBER() OVER (...) <= 2, which is invalid in Spark and PostgreSQL. Windows cannot go in WHERE.
No questions match. Clear search or pick All.