Memory Atlas Β· Data processing

SQL

Build a durable mental model of how Spark plans, moves, and optimizes distributed workβ€”then retrieve it under interview pressure.

Chapters
09
Advanced
04
Mode
Recall

Read for structure. Pause at each memory map and answer before revealing the detail.

Foundation

SQL Overview and Recall Plan

#

SQL Overview and Recall Plan

60-second map

Direct answer: SQL interviews test two things together: precise relational semantics and a small set of reusable query shapes. First define the output grain, then route the problem to filtering, aggregation, a window, a join, a set operation, or one of the 15 analytical patterns.

Memory map: Grain -> rows -> groups -> windows -> joins -> sets -> plan. State the grain, preserve NULL and tie semantics, choose the pattern, and inspect the execution plan when performance matters.

Choose the pattern

Signal in the questionCanonical pattern
Top N, rank, previous row, running metricP1-P3: window functions
Streak, session, latest duplicateP4-P6: hard patterns
Same-table roles, combinations, co-purchasesP7-P9: join patterns
Pivot, retention, date intervalP10-P12: aggregation and cohorts
Tree, percentile, conversion stagesP13-P15: advanced analysis

Interview-solving loop

  1. Say what one output row represents.
  2. Identify the group, partition, join key, and ordering column.
  3. Name the pattern before writing syntax.
  4. Build one logical step per CTE when the query has multiple grains.
  5. Test NULLs, ties, duplicate input, empty groups, first/last rows, and date boundaries.

Dialect guardrails

OperationHive SQLSpark SQLPostgreSQL
Days between datesDATEDIFF(end_date, start_date)DATEDIFF(end_date, start_date)end_date - start_date
Add N daysDATE_ADD(date, N)DATE_ADD(date, N)date + N
Start of monthTRUNC(date, 'MM')DATE_TRUNC('month', date)DATE_TRUNC('month', date)
Current dateCURRENT_DATECURRENT_DATECURRENT_DATE
Recursive CTENot supportedWITH RECURSIVE in Spark 4.1+; use a non-recursive alternative earlierWITH RECURSIVE
PercentilePERCENTILE_APPROX(col, 0.5)PERCENTILE_CONT(0.5) WITHIN GROUPPERCENTILE_CONT(0.5) WITHIN GROUP
String concatenationCONCAT('a','b')CONCAT('a','b') or `'a'
Unix timestampUNIX_TIMESTAMP(datetime)UNIX_TIMESTAMP(datetime)EXTRACT(EPOCH FROM datetime)

SQL Interview Prep β€” Pattern-Based Guide for Data Engineers

πŸ’‘ Interview Tip
Philosophy: Don't memorize 100 answers. Master 15 patterns. Every question becomes recognizable. Level: Medium to Hard (data engineering interview standard) Total Questions: 90 across 15 patterns β€” extensible to 200+

HOW TO USE THIS PREP

πŸ“‹ Overview
STEP 1: See a question→ask "what is it REALLY asking?"
STEP 2: Match to a pattern using the Decision Flowchart below
STEP 3: Apply the pattern TEMPLATE (fill in the blanks)
STEP 4: Build with CTEs (one CTE per logical step)
STEP 5: Test for edge cases (NULLs, ties, first/last row)

THE DECISION FLOWCHART β€” Which Pattern To Use

πŸ—‚οΈLOOK AT THE QUESTION. What does it ask for?
"Top N per group / highest / rank within group"
β†’ PATTERN 1: RANKING (DENSE_RANK, ROW_NUMBER)
"Running total / cumulative sum / month-to-date"
β†’ PATTERN 2: RUNNING TOTALS (SUM OVER ORDER BY)
"Previous row / next row / month-over-month / growth / streak"
β†’ PATTERN 3: LAG / LEAD
"Consecutive days / continuous period / streak / island"
β†’ PATTERN 4: GAPS & ISLANDS (date - ROW_NUMBER trick)
"Group events into sessions / inactivity window"
β†’ PATTERN 5: SESSIONIZATION
"Remove duplicates / keep latest / CDC dedup"
β†’ PATTERN 6: DEDUPLICATION (ROW_NUMBER PARTITION BY)
"Compare rows in same table / manager vs employee / hierarchy"
β†’ PATTERN 7: SELF-JOIN
"All combinations / every pair / Cartesian product"
β†’ PATTERN 8: CROSS JOIN
"Items bought together / co-occurrence / frequently paired"
β†’ PATTERN 9: MARKET BASKET (self-join on order_id)
"Count/sum by category in same row / pivot columns"
β†’ PATTERN 10: CONDITIONAL AGGREGATION (CASE WHEN)
"First time users / retention / returning customers / cohort"
β†’ PATTERN 11: COHORT / RETENTION
"Date difference / interval / overdue / days between events"
β†’ PATTERN 12: DATE ARITHMETIC
"Hierarchy / org chart / tree / all levels / recursive"
β†’ PATTERN 13: RECURSIVE CTE
"Median / percentile / quartile / middle value"
β†’ PATTERN 14: MEDIAN / PERCENTILE
"Funnel / conversion / drop-off / step-by-step users"
β†’ PATTERN 15: FUNNEL ANALYSIS

PATTERN DIFFICULTY HEAT MAP

EASIER ──────────────────────────────── HARDER
P6: Dedup P1: Ranking P3: LAG/LEAD P4: Gaps&Islands
P12: DateArith P10: CaseWhen P2: Running P5: Sessionize
P8: CrossJoin P7: SelfJoin P11: Cohort P13: Recursive
P14: Median P9: MarketBask P15: Funnel

THE 5-STEP SOLVING TEMPLATE (use every time)

sql
-- STEP 1: Understand the grain
--   "What does ONE ROW in the output represent?"
--   Example: one row = one product per category

-- STEP 2: Build intermediate CTEs
WITH raw_data AS (
    -- Clean or filter source data
    SELECT ...
    FROM source_table
    WHERE conditions
),

intermediate AS (
    -- Apply window function or join
    SELECT
        ...,
        ROW_NUMBER() OVER (PARTITION BY group_col ORDER BY sort_col) AS rn
    FROM raw_data
)

-- STEP 3: Filter to final result
SELECT ...
FROM intermediate
WHERE rn = 1;  -- or whatever condition applies

-- STEP 4: Check edge cases in your head:
--   - What if there are NULL values in partition/order columns?
--   - What if a group has only 1 row? (top-2 query returns 1 row per group β€” is that OK?)
--   - Are ties handled correctly? (RANK vs DENSE_RANK)
--   - What if the date column has gaps?

-- STEP 5: Verbalize to interviewer:
--   "First I'm partitioning by X because each group needs its own ranking..."
--   "I chose DENSE_RANK over RANK because I want ties to share the same rank..."

WHAT INTERVIEWERS REALLY EVALUATE

1. PATTERN RECOGNITION (fastest signal)
Do you immediately see "this is a gaps-and-islands" or do you start
writing random JOINs hoping something works?
2. CTE HYGIENE
Do you break complex logic into named CTEs?
Good engineers write readable SQL, not one 30-line mega-query.
3. EDGE CASE AWARENESS
"What if there are NULLs in the join key?"
"What if a user has only one purchase β€” does your LAG() still work?"
4. PERFORMANCE THINKING (senior-level differentiator)
"On 10 billion rows in Snowflake/BigQuery, I'd partition the underlying
table by date to support partition pruning on this query..."
5. TOOL AWARENESS
Know when to say: "In Hive I'd use DISTRIBUTE BY + SORT BY instead of
ORDER BY for this window function to avoid data movement to one reducer."

SYNTAX CHEAT SHEET β€” Standard SQL vs Hive SQL vs Spark SQL

OperationStandard SQL / PostgreSQLHive SQLSpark SQL
Date differenceend_date - start_dateDATEDIFF(end_date,start_date)DATEDIFF(end_date,start_date)
Truncate to monthDATE_TRUNC('month', date)TRUNC(date,'MM')DATE_TRUNC('month',date)
Add daysdate + INTERVAL '7 days'DATE_ADD(date, 7)DATE_ADD(date, 7)
String concat'a''b'
Recursive CTEWITH RECURSIVE cte AS...NOT SUPPORTED*WITH RECURSIVE (Spark 4.1+)
PercentilePERCENTILE_CONT(0.5) ...PERCENTILE(col,.5)PERCENTILE_CONT(0.5)...
Extract yearEXTRACT(YEAR FROM date)YEAR(date)YEAR(date) or EXTRACT
Row numberROW_NUMBER() OVER(...)ROW_NUMBER() OVERROW_NUMBER() OVER
LAG/LEADLAG(col,1) OVER(...)LAG(col,1) OVERLAG(col,1) OVER

* Hive does not support recursive CTEs. Use a prebuilt calendar table, a maintained hierarchy table, or an iterative multi-step job.

* Spark gained recursive CTE support in 4.1. On earlier Spark versions, use an iterative DataFrame/GraphFrames traversal; for date spines, use `EXPLODE(SEQUENCE(...))`.

Foundation

SQL Fundamentals

#

SQL Fundamentals

SQL Fundamentals β€” The Questions Interviewers Always Ask First

βœ… Pro Tip
Why this file exists: Patterns (SQL_01–SQL_06) test problem-solving. But before that, interviewers test foundational understanding. If you fumble ACID or can't explain normalization, you won't reach the coding round. Format: Every topic follows β€” Definition β†’ Simple Explanation β†’ Analogy β†’ Code β†’ Interview Tip β†’ What NOT to say.

Answer First: ACID makes a transaction reliable: all-or-nothing, valid, isolated from concurrent work, and durable after commit.

Memory Map: Atomicity -> Consistency -> Isolation -> Durability; tie each property to the failure it prevents.

SECTION 1: ACID PROPERTIES

Definition (1 line each)

PropertyDefinition
AtomicityA transaction is all-or-nothing β€” every statement succeeds, or none do.
ConsistencyA transaction moves the database from one valid state to another β€” all rules/constraints are satisfied.
IsolationConcurrent transactions behave as if they ran one after another.
DurabilityOnce committed, the data survives crashes, power failures, and restarts.

Simple Explanation

Think of a bank transfer β€” you move β‚Ή5000 from Account A to Account B.

PropertyWhat it means for the transfer
AtomicityEither BOTH the debit from A AND credit to B happen, or NEITHER happens. No half-transfers.
ConsistencyTotal money before = total money after. The system never shows β‚Ή5000 vanished into thin air.
IsolationIf someone checks balances mid-transfer, they see either the before-state or the after-state β€” never the debit without the credit.
DurabilityOnce the bank says "transfer complete," even if the server crashes 1 second later, the transfer is permanent.

Real-world Analogy

ACID is like a legal contract:

  • Atomicity = The contract is either fully signed by both parties or it's void.
  • Consistency = The contract can't violate any laws.
  • Isolation = Two contracts being signed simultaneously don't interfere with each other.
  • Durability = Once signed and filed, a fire in the building doesn't erase the contract (it's backed up).

Code Example β€” Transaction in Action

sql
-- Bank transfer: Move β‚Ή5000 from account 101 to account 202

BEGIN TRANSACTION;

  -- Step 1: Debit sender
  UPDATE accounts
  SET balance = balance - 5000
  WHERE account_id = 101;

  -- Step 2: Credit receiver
  UPDATE accounts
  SET balance = balance + 5000
  WHERE account_id = 202;

  -- Step 3: Log the transfer
  INSERT INTO transfer_log (from_acct, to_acct, amount, txn_time)
  VALUES (101, 202, 5000, CURRENT_TIMESTAMP);

COMMIT;
-- If ANY step fails β†’ ROLLBACK undoes everything (Atomicity)
sql
-- What happens on failure:
BEGIN TRANSACTION;

  UPDATE accounts SET balance = balance - 5000 WHERE account_id = 101;
  -- ^^^ This succeeds

  UPDATE accounts SET balance = balance + 5000 WHERE account_id = 999;
  -- ^^^ Account 999 doesn't exist β€” ERROR

ROLLBACK;
-- Both updates are undone. Account 101 keeps its original balance.

Interview Tip

When asked "Explain ACID," always use one example (bank transfer) and map all 4 properties to it. Don't give 4 separate examples β€” it shows disconnected thinking.

What NOT to say

  • "Atomicity means the transaction is small" β€” No. It means indivisible, not small.
  • "Consistency means data looks the same everywhere" β€” That's replication consistency, not ACID consistency.
  • "Durability means data is replicated" β€” Replication helps, but durability fundamentally means written to non-volatile storage (disk/WAL).

Answer First: Isolation levels trade concurrency for protection from dirty, non-repeatable, and phantom reads.

Memory Map: Read Uncommitted -> Read Committed -> Repeatable Read -> Serializable; protection rises as concurrency falls.

SECTION 2: TRANSACTION ISOLATION LEVELS

The 3 Problems Isolation Levels Solve

ProblemWhat happensExample
Dirty ReadYou read data that another transaction hasn't committed yet. If that transaction rolls back, you read garbage.Transaction A updates a salary to 90K but hasn't committed. Transaction B reads 90K. Transaction A rolls back. B now has a value that never existed.
Non-Repeatable ReadYou read the same row twice and get different values because another transaction modified it between your two reads.You read salary = 80K. Another transaction updates it to 90K and commits. You read again β€” now it's 90K. Same query, different result.
Phantom ReadYou run the same query twice and get different rows because another transaction inserted/deleted rows between your two queries.You run SELECT * FROM employees WHERE dept = 'Eng' β†’ 10 rows. Another transaction inserts a new engineer. You run the same query β†’ 11 rows.

The 4 Isolation Levels

LevelDirty ReadNon-Repeatable ReadPhantom ReadPerformance
READ UNCOMMITTEDPossiblePossiblePossibleFastest
READ COMMITTEDPreventedPossiblePossibleFast
REPEATABLE READPreventedPreventedPossibleMedium
SERIALIZABLEPreventedPreventedPreventedSlowest

Memory trick: Each level going down adds one more guarantee. Think of it as progressively locking more things.

Simple Explanation

  • READ UNCOMMITTED β€” "I'll read whatever is there, even uncommitted changes." Used almost never in production.
  • READ COMMITTED β€” "I'll only read committed data, but if you change it after I read it, I see the new value on my next read." This is the default in PostgreSQL, Oracle, SQL Server.
  • REPEATABLE READ β€” "Once I read a row, its value won't change for the rest of my transaction." This is the default in MySQL/InnoDB.
  • SERIALIZABLE β€” "Full lockdown. Everything behaves as if transactions ran one by one." Safest but slowest.

Code Example

sql
-- Setting isolation level (PostgreSQL syntax)
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;

BEGIN;
  SELECT balance FROM accounts WHERE account_id = 101;
  -- If another transaction changes this row and commits,
  -- a second SELECT in this transaction WILL see the new value.
  -- (Non-repeatable read is possible)
COMMIT;
sql
-- Setting isolation level (MySQL syntax)
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;

START TRANSACTION;
  SELECT balance FROM accounts WHERE account_id = 101;
  -- Result: 50000

  -- Another session updates balance to 60000 and commits.

  SELECT balance FROM accounts WHERE account_id = 101;
  -- Result: STILL 50000 (snapshot from start of transaction)
COMMIT;
sql
-- SQL Server syntax
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;

BEGIN TRANSACTION;
  SELECT * FROM orders WHERE customer_id = 42;
  -- No other transaction can INSERT/UPDATE/DELETE rows
  -- matching customer_id = 42 until this transaction ends.
COMMIT;

Interview Tip

Know your database's default isolation level. PostgreSQL and Oracle default to READ COMMITTED. MySQL/InnoDB defaults to REPEATABLE READ. Being able to say this shows real-world experience.

What NOT to say

  • "SERIALIZABLE means everything runs one at a time" β€” Not literally. The DB uses locking/MVCC to simulate serial execution while allowing some concurrency.
  • "READ UNCOMMITTED is never used" β€” It's used in analytics/reporting where speed matters and slight inconsistency is acceptable.

Answer First: Normalization removes update anomalies by separating facts according to keys and dependencies; denormalize deliberately for read performance.

Memory Map: 1NF atomic values -> 2NF full-key dependency -> 3NF no transitive dependency -> BCNF every determinant is a candidate key.

SECTION 3: NORMALIZATION

What is Normalization?

Definition: The process of organizing tables to minimize redundancy and eliminate update/insert/delete anomalies.

Simple Explanation: Instead of storing everything in one giant spreadsheet with tons of repeated data, you split it into smaller, related tables.

1NF β€” First Normal Form

Rule: Every column holds atomic (indivisible) values. No repeating groups, no arrays, no comma-separated lists.

πŸ“ Architecture Diagram
❌ NOT in 1NF:
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ student β”‚ courses              β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ Alice   β”‚ Math, Physics, Chem  β”‚  ← Multiple values in one cell
β”‚ Bob     β”‚ Math, English        β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

βœ… In 1NF:
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ student β”‚ course   β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ Alice   β”‚ Math     β”‚
β”‚ Alice   β”‚ Physics  β”‚
β”‚ Alice   β”‚ Chem     β”‚
β”‚ Bob     β”‚ Math     β”‚
β”‚ Bob     β”‚ English  β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

2NF β€” Second Normal Form

Rule: Must be in 1NF + every non-key column depends on the ENTIRE primary key (no partial dependencies).

This only matters when you have a composite primary key.

πŸ“ Architecture Diagram
❌ In 1NF but NOT 2NF:
PK = (student_id, course_id)

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ student_id β”‚ course_id β”‚ student_name β”‚ course_name      β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ 1          β”‚ C101      β”‚ Alice        β”‚ Mathematics      β”‚
β”‚ 1          β”‚ C102      β”‚ Alice        β”‚ Physics          β”‚
β”‚ 2          β”‚ C101      β”‚ Bob          β”‚ Mathematics      β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Problem: student_name depends ONLY on student_id (partial dependency)
         course_name depends ONLY on course_id (partial dependency)

βœ… In 2NF β€” Split into 3 tables:

students:          courses:            enrollments:
β”Œβ”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”    β”Œβ”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ id β”‚ name  β”‚    β”‚ id    β”‚ name    β”‚  β”‚ student_id β”‚ course_id β”‚
β”œβ”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€    β”œβ”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€  β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ 1  β”‚ Alice β”‚    β”‚ C101  β”‚ Math    β”‚  β”‚ 1          β”‚ C101      β”‚
β”‚ 2  β”‚ Bob   β”‚    β”‚ C102  β”‚ Physics β”‚  β”‚ 1          β”‚ C102      β”‚
β””β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”˜    β””β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚ 2          β”‚ C101      β”‚
                                       β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

3NF β€” Third Normal Form

Rule: Must be in 2NF + no transitive dependencies (non-key column depends on another non-key column).

πŸ“ Architecture Diagram
❌ In 2NF but NOT 3NF:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ employee_id β”‚ dept_id   β”‚ dept_name      β”‚ dept_head    β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ 1           β”‚ D10       β”‚ Engineering    β”‚ Alice        β”‚
β”‚ 2           β”‚ D10       β”‚ Engineering    β”‚ Alice        β”‚  ← Redundancy!
β”‚ 3           β”‚ D20       β”‚ Marketing      β”‚ Bob          β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Problem: dept_name and dept_head depend on dept_id, NOT on employee_id.
         employee_id β†’ dept_id β†’ dept_name (transitive dependency)

βœ… In 3NF β€” Split:

employees:                    departments:
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ employee_id β”‚ dept_id   β”‚  β”‚ dept_id β”‚ dept_name   β”‚ dept_head β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€  β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ 1           β”‚ D10       β”‚  β”‚ D10     β”‚ Engineering β”‚ Alice     β”‚
β”‚ 2           β”‚ D10       β”‚  β”‚ D20     β”‚ Marketing   β”‚ Bob       β”‚
β”‚ 3           β”‚ D20       β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

BCNF β€” Boyce-Codd Normal Form

Definition: A stricter version of 3NF β€” every determinant must be a candidate key.

When it matters: When a table has multiple overlapping candidate keys. In practice, if your table is in 3NF, it's usually in BCNF too. Know it exists and can explain it if asked, but don't overthink it.

Denormalization β€” When and Why

Definition: Intentionally adding redundancy back into normalized tables to improve read performance.

Use Denormalization When...Don't Denormalize When...
Read-heavy workloads (dashboards, reports)Write-heavy OLTP systems
Query requires many JOINs that slow down readsData integrity is critical
Data warehouse / analytics layerSmall tables where JOINs are cheap
Pre-aggregated tables for BI toolsFrequently updated columns
sql
-- Normalized: Requires JOIN every time
SELECT o.order_id, c.customer_name, o.total
FROM orders o
JOIN customers c ON o.customer_id = c.customer_id;

-- Denormalized: customer_name stored directly in orders table
-- Faster reads, but you must update it everywhere if the name changes
SELECT order_id, customer_name, total
FROM orders_denormalized;

Interview Tip

Say: "In OLTP systems I normalize to 3NF to prevent anomalies. In data warehouses and analytics, I denormalize (star schema / snowflake schema) because reads vastly outnumber writes and JOINs are expensive at scale."

What NOT to say

  • "Always normalize" β€” Shows no practical experience with data warehousing.
  • "1NF means each cell has one value, and that's basically it" β€” You must also mention no duplicate rows (need a primary key).

Answer First: An index trades write/storage cost for faster row location; validate it with the execution plan, not intuition.

Memory Map: Predicate/selectivity -> candidate index -> EXPLAIN access path -> measured reads and latency.

SECTION 4: INDEXES AND QUERY OPTIMIZATION

What is an Index?

Definition: A data structure (typically a B-Tree) that allows the database to find rows without scanning the entire table.

Simple Explanation: An index is like the index at the back of a textbook. Instead of reading every page to find "ACID properties," you look up "ACID" in the index β†’ it says "page 47" β†’ you go directly there.

How B-Tree Index Works (Simplified)

sql
Without index: Full Table Scan β€” reads ALL 10 million rows
With index:    B-Tree lookup β€” reads ~20 nodes to find the row

B-Tree structure (balanced tree):
                    [M]
                   /   \
               [D,H]   [R,V]
              / | \    / | \
           [A-C][E-G][I-L][N-Q][S-U][W-Z]
                              ↓
                         Points to actual rows on disk

Clustered vs Non-Clustered Index

FeatureClustered IndexNon-Clustered Index
What it doesSorts and stores the actual table data in orderCreates a separate structure pointing to table data
How many per tableOnly 1 (because data can only be physically sorted one way)Multiple (as many as needed)
SpeedFaster for range queries on the indexed columnSlightly slower (extra lookup to actual data)
DefaultPrimary key creates a clustered index by default (in SQL Server, MySQL/InnoDB)Manually created indexes are non-clustered
AnalogyA phone book sorted by last name (the data IS the index)An index at the back of a book (separate from the content)

When to Create an Index / When NOT to

Create Index WhenDon't Create Index When
Column is used in WHERE, JOIN, ORDER BY frequentlyTable is small (< few thousand rows) β€” full scan is faster
Column has high cardinality (many distinct values)Column has low cardinality (e.g., gender: M/F)
Table is read-heavyTable is write-heavy (indexes slow down INSERT/UPDATE/DELETE)
Query returns a small % of rowsQuery returns most rows (index won't help)

Code Examples

sql
-- Basic index
CREATE INDEX idx_customers_email
ON customers (email);

-- Composite index (multi-column β€” order matters!)
CREATE INDEX idx_orders_cust_date
ON orders (customer_id, order_date);
-- βœ… This helps: WHERE customer_id = 42 AND order_date > '2025-01-01'
-- βœ… This helps: WHERE customer_id = 42 (leftmost prefix)
-- ❌ This does NOT help: WHERE order_date > '2025-01-01' (skips first column)

-- Covering index: includes all columns the query needs
-- The DB can answer the query entirely from the index without touching the table
CREATE INDEX idx_orders_covering
ON orders (customer_id, order_date)
INCLUDE (total_amount, status);

-- Unique index
CREATE UNIQUE INDEX idx_users_email
ON users (email);

-- Drop an index
DROP INDEX idx_customers_email;

How to Read EXPLAIN PLAN

sql
-- PostgreSQL
EXPLAIN ANALYZE
SELECT * FROM orders
WHERE customer_id = 42
  AND order_date > '2025-01-01';

Key things to look for in the output:

What you seeWhat it meansGood or Bad?
Seq ScanFull table scan β€” reading every rowBad for large tables
Index ScanUsing an index to find rowsGood
Index Only ScanAnswered entirely from index (covering index)Best
Bitmap Index ScanIndex scan + bitmap for multiple conditionsGood
Nested LoopFor each row in table A, scan table BBad for large tables
Hash JoinBuild hash table on smaller table, probe with largerGood for equi-joins
SortSorting in memory or on diskWatch for Sort Method: external merge (disk sort = slow)
actual timeReal execution time in millisecondsLower = better
rowsEstimated vs actual rows returnedLarge mismatch = stale statistics, run ANALYZE
sql
-- Example EXPLAIN output (PostgreSQL):
-- Index Scan using idx_orders_cust_date on orders
--   Index Cond: (customer_id = 42)
--   Filter: (order_date > '2025-01-01')
--   Rows Removed by Filter: 12
--   actual time=0.045..0.089 rows=38 loops=1

-- Translation: Used the index on customer_id, then filtered by date.
-- Very fast (0.089 ms). Returned 38 rows.

Interview Tip

πŸ’‘ Interview Tip
If an interviewer asks "How would you optimize this slow query?", follow this framework:
  1. Run EXPLAIN β€” find the bottleneck (Seq Scan? Sort on disk? Nested Loop?)
  2. Check indexes β€” is the WHERE/JOIN column indexed?
  3. Check statistics β€” ANALYZE table_name to update stats
  4. Check query rewrite β€” Can you avoid SELECT *? Can you push filters earlier?

What NOT to say

  • "Just add an index on every column" β€” Too many indexes slow down writes and waste storage.
  • "I'd look at the query and guess what's slow" β€” Always say you'd check EXPLAIN first.

Answer First: Use JOIN to combine rows, EXISTS to test existence, and IN for a small or clear membership set; guard NOT IN against NULL.

Memory Map: Need columns? JOIN. Need a boolean? EXISTS. Need membership? IN. Then inspect NULL semantics and the plan.

SECTION 5: IN vs EXISTS vs JOIN β€” PERFORMANCE

Quick Comparison

FeatureINEXISTSJOIN
Use whenChecking against a small list or subquery with few resultsChecking existence in a correlated subqueryYou need columns from both tables
ReturnsMatches from the listTRUE/FALSE (stops at first match)All matching rows (including duplicates if not careful)
NULL handlingDangerous β€” IN (NULL) never matchesSafe β€” handles NULLs properlyDepends on join type
PerformanceGood for small lists, bad for large subqueriesGood for large subqueries (short-circuits)Usually best for large tables (optimizer handles well)

Code Examples β€” Same Question, 3 Approaches

Question: Find customers who have placed at least one order.

sql
-- Approach 1: IN
SELECT customer_name
FROM customers
WHERE customer_id IN (
    SELECT customer_id FROM orders
);
-- Works fine for small orders table
-- PROBLEM: If orders.customer_id has NULLs, IN can behave unexpectedly

-- Approach 2: EXISTS (generally preferred for existence checks)
SELECT customer_name
FROM customers c
WHERE EXISTS (
    SELECT 1 FROM orders o
    WHERE o.customer_id = c.customer_id
);
-- Stops scanning orders as soon as it finds ONE match (short-circuit)
-- Handles NULLs safely

-- Approach 3: JOIN
SELECT DISTINCT c.customer_name
FROM customers c
INNER JOIN orders o ON c.customer_id = o.customer_id;
-- Need DISTINCT because a customer with 5 orders appears 5 times
-- Best when you also need order columns in the output

The NULL Trap with IN and NOT IN

sql
-- This is the classic trap interviewers love:

-- Suppose orders.customer_id has values: (1, 2, NULL)

-- NOT IN with NULLs: RETURNS NOTHING!
SELECT * FROM customers
WHERE customer_id NOT IN (SELECT customer_id FROM orders);
-- SQL evaluates: customer_id != 1 AND customer_id != 2 AND customer_id != NULL
-- Anything compared to NULL β†’ UNKNOWN β†’ entire WHERE clause β†’ UNKNOWN β†’ row excluded
-- Result: ZERO rows returned, regardless of data!

-- NOT EXISTS: Works correctly
SELECT * FROM customers c
WHERE NOT EXISTS (
    SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id
);
-- Returns customers who genuinely have no orders

Performance Rules of Thumb

ScenarioBest ChoiceWhy
Small static listIN ('A', 'B', 'C')Simple, readable
"Does a matching row exist?"EXISTSShort-circuits, NULL-safe
Large tables, need columns from bothJOINOptimizer can use hash/merge join
Anti-join (rows that DON'T match)NOT EXISTS or LEFT JOIN ... IS NULLNOT IN fails with NULLs

Interview Tip

If asked "IN or EXISTS?", say: "For existence checks, I default to EXISTS because it short-circuits and handles NULLs safely. For small literal lists, IN is fine. But honestly, modern optimizers often generate the same execution plan for both β€” I'd verify with EXPLAIN."

What NOT to say

  • "IN and EXISTS are the same thing" β€” They handle NULLs differently, and EXISTS short-circuits.
  • "JOIN is always faster" β€” Not always. For a simple existence check, EXISTS avoids the need for DISTINCT.

Answer First: NULL means unknown or missing, so comparisons use IS NULL and arithmetic/aggregates require explicit intent.

Memory Map: Three-valued logic -> joins -> aggregates -> COALESCE/NULLIF -> division and NOT IN traps.

SECTION 6: NULL HANDLING

What is NULL?

Definition: NULL means unknown or missing β€” it is NOT zero, NOT an empty string, NOT false.

Key rule: Any operation involving NULL produces NULL (with a few exceptions).

NULL Behavior Cheat Sheet

OperationResultWhy
NULL = NULLNULL (not TRUE!)Unknown = Unknown β†’ Unknown
NULL != NULLNULL (not TRUE!)Same reason
NULL > 5NULLCan't compare unknown to 5
NULL + 10NULLUnknown + 10 = Unknown
NULL AND TRUENULLUnknown AND TRUE = Unknown
NULL OR TRUETRUEEven if unknown is FALSE, TRUE OR FALSE = TRUE
NULL AND FALSEFALSEEven if unknown is TRUE, TRUE AND FALSE = FALSE

IS NULL vs = NULL

sql
-- ❌ WRONG β€” this will NEVER find NULL rows
SELECT * FROM employees WHERE manager_id = NULL;
-- Evaluates to NULL (not TRUE), so no rows returned

-- βœ… CORRECT
SELECT * FROM employees WHERE manager_id IS NULL;

-- βœ… Also correct
SELECT * FROM employees WHERE manager_id IS NOT NULL;

NULL in Aggregations

sql
-- Sample data:
-- sales: (100, 200, NULL, 300, NULL)

SELECT
    COUNT(*)          AS total_rows,      -- 5 (counts ALL rows including NULLs)
    COUNT(amount)     AS non_null_count,  -- 3 (skips NULLs)
    SUM(amount)       AS total,           -- 600 (NULLs ignored)
    AVG(amount)       AS average,         -- 200 (600/3, NOT 600/5!)
    MIN(amount)       AS minimum,         -- 100 (NULLs ignored)
    MAX(amount)       AS maximum          -- 300 (NULLs ignored)
FROM sales;

-- KEY INSIGHT: AVG ignores NULLs.
-- If you want NULLs treated as 0:
SELECT AVG(COALESCE(amount, 0)) FROM sales;  -- 600/5 = 120

NULL in JOINs

sql
-- NULLs NEVER match in JOINs
-- If orders.customer_id = NULL and customers.customer_id = NULL,
-- they will NOT join together because NULL = NULL β†’ NULL (not TRUE)

SELECT *
FROM orders o
JOIN customers c ON o.customer_id = c.customer_id;
-- Rows with NULL customer_id are silently dropped from both sides

COALESCE, NULLIF, IFNULL

sql
-- COALESCE: Returns the first non-NULL value
SELECT COALESCE(phone, mobile, email, 'No Contact')
FROM employees;
-- If phone is NULL β†’ try mobile β†’ try email β†’ fallback to 'No Contact'

-- COALESCE for safe calculations
SELECT
    employee_name,
    base_salary + COALESCE(bonus, 0) + COALESCE(commission, 0) AS total_comp
FROM employees;
-- Without COALESCE: if bonus is NULL, total_comp becomes NULL

-- NULLIF: Returns NULL if the two values are equal
SELECT NULLIF(actual_count, 0)  -- Returns NULL if actual_count is 0
FROM inventory;
-- Useful to prevent division-by-zero:
SELECT total / NULLIF(count, 0) AS average  -- Returns NULL instead of error
FROM summary;

-- IFNULL (MySQL) / ISNULL (SQL Server): Two-argument COALESCE
SELECT IFNULL(phone, 'N/A') FROM employees;        -- MySQL
SELECT ISNULL(phone, 'N/A') FROM employees;        -- SQL Server
SELECT COALESCE(phone, 'N/A') FROM employees;      -- Standard SQL (use this)

Interview Tip

⚠️ Common Trap
The #1 NULL trap interviewers set: COUNT(*) vs COUNT(column). Always clarify: "COUNT(*) counts all rows; COUNT(column) skips NULLs." Then mention that AVG also skips NULLs, which changes the denominator.

What NOT to say

  • "NULL means zero" or "NULL means empty string" β€” NULL means unknown. '' != NULL in most databases (except Oracle, where '' = NULL).
  • "I just use WHERE column = NULL" β€” Must use IS NULL. This is a dealbreaker mistake.

Answer First: Straight SQL questions test semantic differencesβ€”data removal, filtering phase, duplicate handling, constraints, and reusable database objects.

Memory Map: Classify by effect: rows -> groups -> sets -> storage -> constraints -> reusable logic.

SECTION 7: COMMON STRAIGHT QUESTIONS (Quick-Fire Q&A)

DELETE vs TRUNCATE vs DROP

FeatureDELETETRUNCATEDROP
What it doesRemoves specific rows (with WHERE)Removes all rowsRemoves the entire table (structure + data)
WHERE clauseYesNoN/A
LoggedFully logged (row by row)Minimally logged (deallocates pages)Fully logged
RollbackYes (within transaction)Depends on DB (Yes in PostgreSQL, No in Oracle)Depends on DB
Triggers firedYesNoNo
Resets identity/auto-incrementNoYesN/A
SpeedSlowestFastFast
sql
DELETE FROM orders WHERE order_date < '2020-01-01';  -- Remove old orders
TRUNCATE TABLE temp_staging;                          -- Empty staging table
DROP TABLE IF EXISTS old_backup;                      -- Remove table entirely

WHERE vs HAVING

FeatureWHEREHAVING
FiltersIndividual rows BEFORE groupingGroups AFTER GROUP BY
Can use aggregates?NoYes
Execution orderRuns firstRuns after GROUP BY
sql
-- WHERE filters rows, HAVING filters groups
SELECT department, COUNT(*) AS emp_count
FROM employees
WHERE status = 'active'          -- Filter rows BEFORE grouping
GROUP BY department
HAVING COUNT(*) > 5;             -- Filter groups AFTER grouping

Execution order: FROM β†’ WHERE β†’ GROUP BY β†’ HAVING β†’ SELECT β†’ ORDER BY β†’ LIMIT

UNION vs UNION ALL

FeatureUNIONUNION ALL
DuplicatesRemoves duplicatesKeeps all rows (including duplicates)
PerformanceSlower (needs sort/distinct)Faster (no dedup step)
When to useWhen you truly need unique resultsDefault choice β€” use this unless you need dedup
sql
-- UNION ALL is almost always what you want in data pipelines
SELECT name FROM employees_us
UNION ALL
SELECT name FROM employees_eu;

-- UNION only when dedup is needed
SELECT email FROM customers
UNION
SELECT email FROM newsletter_subscribers;

CHAR vs VARCHAR

FeatureCHAR(n)VARCHAR(n)
StorageFixed-length (always uses n bytes, padded with spaces)Variable-length (uses only what's needed + overhead)
PerformanceSlightly faster for fixed-length dataBetter for variable-length data
Use forCountry codes (CHAR(2)), state codes (CHAR(2)), flags (CHAR(1))Names, emails, addresses β€” anything variable
sql
CREATE TABLE users (
    country_code  CHAR(2),        -- Always exactly 2 chars: 'US', 'IN', 'UK'
    email         VARCHAR(255)    -- Could be 5 chars or 200 chars
);

PRIMARY KEY vs UNIQUE KEY

FeaturePRIMARY KEYUNIQUE KEY
NULLsNot allowedAllowed (one NULL in most DBs, multiple in PostgreSQL)
How many per tableOnly 1Multiple
Creates indexYes (clustered by default in SQL Server/MySQL)Yes (non-clustered)
PurposeUniquely identifies each rowEnforces uniqueness on alternate columns
sql
CREATE TABLE employees (
    employee_id   INT PRIMARY KEY,             -- One per table, no NULLs
    email         VARCHAR(255) UNIQUE,         -- Must be unique, but can be NULL
    ssn           VARCHAR(11) UNIQUE           -- Another unique constraint
);

View vs Materialized View

FeatureViewMaterialized View
Stores data?No (it's a saved query)Yes (stores query results on disk)
PerformanceRe-runs query every timeFast reads (pre-computed)
FreshnessAlways currentStale until refreshed
Use caseAccess control, simplify complex queriesDashboards, expensive aggregations
sql
-- Regular View (no data stored)
CREATE VIEW active_customers AS
SELECT customer_id, name, email
FROM customers
WHERE status = 'active';

-- Materialized View (PostgreSQL)
CREATE MATERIALIZED VIEW monthly_sales AS
SELECT DATE_TRUNC('month', order_date) AS month,
       SUM(total) AS revenue
FROM orders
GROUP BY 1;

-- Must manually refresh:
REFRESH MATERIALIZED VIEW monthly_sales;

Stored Procedure vs Function

FeatureStored ProcedureFunction
ReturnsZero, one, or multiple result setsMust return a single value or table
Used in SELECT?NoYes (SELECT my_function(col))
Side effectsCan INSERT, UPDATE, DELETEUsually read-only (varies by DB)
Transaction controlCan use BEGIN/COMMIT/ROLLBACKCannot (in most DBs)
Use caseComplex business logic, ETL stepsCalculations, transformations
sql
-- Stored Procedure (PostgreSQL)
CREATE PROCEDURE transfer_funds(sender INT, receiver INT, amount DECIMAL)
LANGUAGE plpgsql AS $$
BEGIN
    UPDATE accounts SET balance = balance - amount WHERE id = sender;
    UPDATE accounts SET balance = balance + amount WHERE id = receiver;
    COMMIT;
END;
$$;

CALL transfer_funds(101, 202, 5000);

-- Function (PostgreSQL)
CREATE FUNCTION get_full_name(first_name TEXT, last_name TEXT)
RETURNS TEXT AS $$
BEGIN
    RETURN first_name || ' ' || last_name;
END;
$$ LANGUAGE plpgsql;

SELECT get_full_name(first_name, last_name) FROM employees;

Trigger β€” What Is It?

Definition: A trigger is a block of code that automatically executes when a specific event (INSERT, UPDATE, DELETE) happens on a table.

sql
-- Audit trigger: Log every salary change
CREATE TRIGGER log_salary_change
AFTER UPDATE OF salary ON employees
FOR EACH ROW
BEGIN
    INSERT INTO salary_audit (employee_id, old_salary, new_salary, changed_at)
    VALUES (OLD.employee_id, OLD.salary, NEW.salary, CURRENT_TIMESTAMP);
END;
πŸ“ Note
Interview note: Be ready to say when NOT to use triggers: "Triggers add hidden logic that's hard to debug and can cause cascading performance issues. I prefer application-level logic or CDC (Change Data Capture) in data engineering pipelines."

Temp Table vs CTE vs Subquery

FeatureTemp TableCTE (WITH ... AS)Subquery
ScopeEntire session (until dropped or session ends)Single query onlySingle query only
Indexed?Yes (you can add indexes)NoNo
Materialized?Yes (data stored in tempdb)Usually not (inlined by optimizer)Usually not
Reusable in same query?YesYes (reference multiple times)No (must repeat)
Use caseComplex ETL, need intermediate results across multiple queriesBreaking complex queries into readable stepsSimple one-off filters
sql
-- Temp Table
CREATE TEMPORARY TABLE high_value_orders AS
SELECT * FROM orders WHERE total > 10000;
-- Can now run multiple queries against it, add indexes, etc.

-- CTE (preferred for interview coding)
WITH high_value AS (
    SELECT * FROM orders WHERE total > 10000
),
customer_totals AS (
    SELECT customer_id, SUM(total) AS total_spent
    FROM high_value
    GROUP BY customer_id
)
SELECT * FROM customer_totals WHERE total_spent > 50000;

-- Subquery (avoid for complex logic β€” hard to read)
SELECT *
FROM (
    SELECT customer_id, SUM(total) AS total_spent
    FROM orders
    WHERE total > 10000
    GROUP BY customer_id
) sub
WHERE total_spent > 50000;

Interview Tip

πŸ’‘ Interview Tip
In interviews, always use CTEs. They show structured thinking, are easy to explain step by step, and make your SQL readable. Say: "I prefer CTEs for clarity, but in production ETL I might use temp tables if I need to index intermediate results."

QUICK REFERENCE CARD β€” Print This Page

sql
ACID:    Atomicity (all-or-nothing) | Consistency (valid state) | Isolation (no interference) | Durability (survives crash)

ISOLATION LEVELS (low β†’ high protection):
  READ UNCOMMITTED β†’ READ COMMITTED β†’ REPEATABLE READ β†’ SERIALIZABLE

NORMAL FORMS:
  1NF: Atomic values, no repeating groups
  2NF: 1NF + no partial dependencies
  3NF: 2NF + no transitive dependencies

INDEX RULES:
  Create: High cardinality + used in WHERE/JOIN + read-heavy table
  Skip:   Low cardinality + write-heavy table + small table

NULL RULES:
  NULL = NULL β†’ NULL (not TRUE!)
  Use IS NULL, never = NULL
  COUNT(*) counts NULLs, COUNT(col) skips them
  AVG skips NULLs (changes denominator!)
  NOT IN with NULLs β†’ returns NOTHING. Use NOT EXISTS.

QUICK PAIRS:
  DELETE (rows, logged) vs TRUNCATE (all rows, fast) vs DROP (table gone)
  WHERE (before GROUP BY) vs HAVING (after GROUP BY)
  UNION (dedup) vs UNION ALL (keep all β€” faster, default choice)
  View (saved query) vs Materialized View (saved result)
  CTE (readable, single query) vs Temp Table (persistent, indexable)
Intermediate

SQL Window Functions

#

SQL Window Functions

SQL Patterns 1–3: Window Functions β€” Ranking, Running Totals, LAG/LEAD

πŸ’‘ Interview Tip
The most commonly tested patterns in data engineering interviews. Master these 3 and you can answer ~40% of all medium SQL questions.

🧠 MEMORY MAP

🧠 WINDOW FUNCTIONS = "RRC"
WINDOW FUNCTIONS"RRC"
RROW_NUMBER / RANK / DENSE_RANK (Pattern 1: Ranking)
RRunning SUM/AVG (Pattern 2: Cumulative)
CCurrent vs Previous/Next (LAG/LEAD) (Pattern 3: Comparison)
RANK vs DENSE_RANK vs ROW_NUMBER:
Data: scores = [100, 90, 90, 80]
ROW_NUMBER: 1, 2, 3, 4 (always unique, no ties, just sequential)
RANK: 1, 2, 2, 4 (ties share rank, SKIPS next rank)
DENSE_RANK: 1, 2, 2, 3 (ties share rank, does NOT skip)
RULE: "Second highest with ties→DENSE_RANK"
"Deduplicate keeping one row→ROW_NUMBER"
"Leaderboard where ties exist→RANK or DENSE_RANK"

Answer First: Aggregate at the requested grain, rank inside each group, then filter the rank in an outer query.

Memory Map: Grain -> aggregate -> PARTITION BY group -> ORDER BY metric -> choose ROW_NUMBER/RANK/DENSE_RANK -> filter.

PATTERN 1: RANKING / TOP-N PER GROUP

What Is It?

Find the top N records within each partition (category, department, company). "Top 2 products per category", "Top 3 salaries per department", "2nd highest per group"

Recognize It When You See:

  • "Top N per [group]"
  • "Highest/lowest within each [group]"
  • "Nth [something] in each [group]"
  • "Rank [users/products/employees] by [metric] within [group]"

THE TEMPLATE

sql
-- TEMPLATE: Top N per group
WITH ranked AS (
    SELECT
        group_col,
        value_col,
        other_cols,
        DENSE_RANK() OVER (
            PARTITION BY group_col          -- reset rank for each group
            ORDER BY value_col DESC         -- rank by this (DESC = highest first)
        ) AS rnk
    FROM source_table
)
SELECT group_col, value_col, other_cols
FROM ranked
WHERE rnk <= N;   -- change N to 1, 2, 3 etc.

When to Use Which Rank Function

🧠 Memory Map
ROW_NUMBER()β†’always unique
Use for: DEDUPLICATION (keep exactly 1 row per group)
Use for: Pagination (rows 11-20 of a result set)
RANK() β†’ ties share rank, next rank skips (1,2,2,4)
Use for: "What is this person's rank?" (true competition rank)
Use for: Sports leaderboards
DENSE_RANK() β†’ ties share rank, no skipping (1,2,2,3)
Use for: "Top 2 per group" WITH ties (both tied 2nd place people should appear)
Use for: Most "top N" interview questions (safer default!)
⚠️TRAP: "Top 2 per category" with RANK() might return 3 rows per category
if two products are tied for 2nd! Use DENSE_RANK to handle this correctly.

SOLVED EXAMPLE β€” Q01 (Amazon): Top 2 highest-grossing products per category

Problem:
Table: product_spend (category, product, user_id, spend, transaction_date)
Find the top 2 highest-grossing products per category in 2022.
Return: category, product, total_spend
Step-by-step thinking:
1. Grain of output: one row per (category, product) with total spend
2. Partition: by category (reset rank for each category)
3. Order: by total spend DESC (highest grossing first)
4. Filter: keep only rank 1 and 2
sql
WITH category_spend AS (
    -- Step 1: Aggregate total spend per category+product pair
    SELECT
        category,
        product,
        SUM(spend) AS total_spend
    FROM product_spend
    WHERE EXTRACT(YEAR FROM transaction_date) = 2022
    GROUP BY category, product
),

ranked_products AS (
    -- Step 2: Rank products within each category by spend
    SELECT
        category,
        product,
        total_spend,
        DENSE_RANK() OVER (
            PARTITION BY category
            ORDER BY total_spend DESC
        ) AS spend_rank
    FROM category_spend
)

-- Step 3: Keep only top 2
SELECT category, product, total_spend
FROM ranked_products
WHERE spend_rank <= 2
ORDER BY category, spend_rank;

SOLVED EXAMPLE β€” Q02: Top 3 salaries per department

sql
WITH dept_salary_rank AS (
    SELECT
        e.name AS employee_name,
        d.name AS department_name,
        e.salary,
        DENSE_RANK() OVER (
            PARTITION BY e.department_id
            ORDER BY e.salary DESC
        ) AS salary_rank
    FROM employees e
    JOIN departments d ON e.department_id = d.id
)

SELECT department_name, employee_name, salary
FROM dept_salary_rank
WHERE salary_rank <= 3;

-- ⚠️ Edge case: if a dept has only 2 employees β†’ returns 2 rows (correct!)
-- ⚠️ Ties: if 3rd and 4th salary are equal β†’ DENSE_RANK returns BOTH at rank 3
--    Both are "Top 3" so this is CORRECT behavior for interview questions

PATTERN 1 QUESTIONS (from Question Bank)

Q#QuestionKey Insight

Q01: Top 2 highest-grossing products per category (Amazon)

Approach: Aggregate first, then DENSE_RANK

Q02: Top 3 salaries per department

Approach: DENSE_RANK + PARTITION BY dept

Q03: Email activity rank per user

Approach: DENSE_RANK on SUM of multiple columns

Q04: Top 2 users per company with most calls (keep ties)

Approach: DENSE_RANK specifically to keep ties

Q05: Most-used vehicle type past year

Approach: GROUP BY + ORDER BY + LIMIT 1 (no window needed!)

Q06: Olympic swimmers with only gold medals

Approach: HAVING COUNT(medal) = COUNT(CASE WHEN medal='Gold')

Q07: Nominee who won most Oscars

Approach: Simple GROUP BY + ORDER BY + LIMIT

Q08: Top 10 users by total ride distance

Approach: SUM + JOIN + ORDER BY + LIMIT

Q09: Top 5 product pairs (combined P1+P9)

Approach: See Market Basket pattern

Q10: Top 3 departments by average salary

Approach: RANK on AVG(salary) PARTITION BY nothing

Answer First: A running or rolling metric is a window aggregate with an explicit order and frame.

Memory Map: Partition -> order -> ROWS frame -> SUM/AVG -> verify ties and reset boundaries.

PATTERN 2: RUNNING TOTALS / CUMULATIVE AGGREGATES

What Is It?

A metric that accumulates over time within a partition. "Running total of sales", "Cumulative signups per month", "Moving 7-day average"

Recognize It When You See:

  • "Running total / cumulative"
  • "Month-to-date / year-to-date"
  • "Rolling N-day average"
  • "Balance at end of each day"
  • "Cumulative X from the start"

THE TEMPLATE

sql
-- TEMPLATE: Running total / cumulative sum
SELECT
    partition_col,
    date_col,
    daily_value,

    -- Cumulative sum (reset per partition)
    SUM(daily_value) OVER (
        PARTITION BY partition_col      -- reset for each partition (optional)
        ORDER BY date_col               -- accumulate in this order
        ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW  -- from start to now
    ) AS running_total,

    -- Rolling N-day average (e.g., 7-day)
    AVG(daily_value) OVER (
        PARTITION BY partition_col
        ORDER BY date_col
        ROWS BETWEEN 6 PRECEDING AND CURRENT ROW  -- current + 6 previous = 7 rows
    ) AS rolling_7day_avg

FROM daily_metrics;

Window Frame Syntax

🧠 ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW β†’ cumulative from start
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROWcumulative from start
ROWS BETWEEN 6 PRECEDING AND CURRENT ROWrolling 7-day window
ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING3-row centered moving avg
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWINGgrand total on every row
⚠️TRAP: If you omit ROWS BETWEEN β†’ default is RANGE BETWEEN UNBOUNDED PRECEDING
AND CURRENT ROW. For most cases this works, but for ties in the ORDER BY
column it can include/exclude rows unexpectedly. Use ROWS BETWEEN explicitly.
⚠️TRAP: If you omit ORDER BY inside OVER() β†’ window = ALL rows in partition
β†’ you get GRAND TOTAL on every row, not a running total!

SOLVED EXAMPLE β€” Q11 (Visa): Cumulative merchant balance, reset each month

Problem:
Table: transactions (transaction_date, merchant_id, amount)
Calculate monthly running balance per merchant.
Each month resets to zero. Show: date, amount, monthly_running_balance
sql
WITH monthly_txns AS (
    SELECT
        merchant_id,
        transaction_date,
        amount,
        -- Extract year+month for the PARTITION (reset each month)
        DATE_TRUNC('month', transaction_date) AS txn_month
    FROM transactions
)

SELECT
    merchant_id,
    transaction_date,
    amount,
    SUM(amount) OVER (
        PARTITION BY merchant_id, txn_month  -- partition = merchant + month β†’ resets monthly
        ORDER BY transaction_date            -- accumulate in date order within the month
        ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
    ) AS monthly_running_balance
FROM monthly_txns
ORDER BY merchant_id, transaction_date;

SOLVED EXAMPLE β€” Q19 (Twitter): 3-day rolling average of tweets per user

sql
WITH daily_tweets AS (
    SELECT
        user_id,
        tweet_date,
        COUNT(*) AS tweet_count
    FROM tweets
    GROUP BY user_id, tweet_date
)

SELECT
    user_id,
    tweet_date,
    tweet_count,
    ROUND(
        AVG(tweet_count) OVER (
            PARTITION BY user_id
            ORDER BY tweet_date
            ROWS BETWEEN 2 PRECEDING AND CURRENT ROW  -- 3 rows: today + 2 before
        ), 2
    ) AS rolling_3day_avg
FROM daily_tweets
ORDER BY user_id, tweet_date;

PATTERN 2 QUESTIONS

Q#QuestionKey Insight

Q11: Cumulative merchant balance, reset each month

Approach: PARTITION BY merchant + month

Q12: Cumulative users added daily, reset each month

Approach: DATE_TRUNC to create monthly partition

Q13: Running total revenue by product category

Approach: PARTITION BY category, ORDER BY date

Q14: Cumulative salary for 3 months excluding most recent

Approach: ROWS BETWEEN + date filter

Q15: Total server uptime across overlapping windows

Approach: Merge intervals first (harder β€” LEAD approach)

Q16: Month-over-month revenue change

Approach: SUM monthly β†’ then LAG (combines P2+P3)

Answer First: LAG and LEAD expose the previous or next row so changes, intervals, and sequences can be calculated without a self-join.

Memory Map: Partition -> chronological order -> LAG/LEAD -> NULL edge row -> compare or calculate delta.

PATTERN 3: LAG / LEAD β€” ROW-OVER-ROW COMPARISON

What Is It?

Compare each row to the row before it (LAG) or after it (LEAD). Used for: growth rates, detecting sequences, finding time between events.

Recognize It When You See:

  • "Month-over-month / year-over-year change"
  • "Previous [value]" or "next [event]"
  • "Time between [event A] and [event B]"
  • "Did [X happen immediately after Y]?"
  • "Detect duplicate/repeated [events]"

THE TEMPLATE

sql
-- TEMPLATE: LAG / LEAD comparison
SELECT
    partition_col,
    date_col,
    value_col,

    -- Previous row's value
    LAG(value_col, 1) OVER (
        PARTITION BY partition_col
        ORDER BY date_col
    ) AS prev_value,

    -- Compute change
    value_col - LAG(value_col, 1) OVER (
        PARTITION BY partition_col
        ORDER BY date_col
    ) AS change_from_prev,

    -- % change
    ROUND(
        100.0 * (value_col - LAG(value_col,1) OVER (PARTITION BY partition_col ORDER BY date_col))
        / NULLIF(LAG(value_col,1) OVER (PARTITION BY partition_col ORDER BY date_col), 0),
    2) AS pct_change,

    -- Next row's value
    LEAD(value_col, 1) OVER (
        PARTITION BY partition_col
        ORDER BY date_col
    ) AS next_value

FROM source_table;

-- ⚠️ NULLIF(..., 0) prevents division by zero when previous value = 0
-- ⚠️ First row: LAG returns NULL (no previous row) β€” handle with COALESCE if needed

SOLVED EXAMPLE β€” Q17 (Stripe): Detect duplicate payments within 10 minutes

Problem:
Table: transactions (transaction_id, merchant_id, credit_card_id, amount, transaction_timestamp)
Find duplicate payments: same merchant + same card + same amount, within 10 minutes.
Return: count of such duplicate pairs.
sql
WITH payment_gaps AS (
    SELECT
        merchant_id,
        credit_card_id,
        amount,
        transaction_timestamp,

        -- Get the PREVIOUS transaction timestamp for same merchant+card+amount
        LAG(transaction_timestamp) OVER (
            PARTITION BY merchant_id, credit_card_id, amount
            ORDER BY transaction_timestamp
        ) AS prev_timestamp
    FROM transactions
)

SELECT COUNT(*) AS duplicate_count
FROM payment_gaps
WHERE
    prev_timestamp IS NOT NULL  -- not the first transaction in this group
    AND transaction_timestamp - prev_timestamp <= INTERVAL '10 minutes';
    -- In Hive/Spark: (UNIX_TIMESTAMP(transaction_timestamp) - UNIX_TIMESTAMP(prev_timestamp)) <= 600

SOLVED EXAMPLE β€” Q20 (Apple): AirPod purchase directly after iPhone

Problem:
Table: purchases (user_id, product_name, purchase_date)
Find % of users who bought AirPods as their VERY NEXT purchase after buying iPhone.
Return: airpod_after_iphone_pct
sql
WITH purchase_sequence AS (
    SELECT
        user_id,
        product_name,
        -- Get the NEXT purchase for each user
        LEAD(product_name, 1) OVER (
            PARTITION BY user_id
            ORDER BY purchase_date
        ) AS next_product
    FROM purchases
),

iphone_buyers AS (
    SELECT COUNT(DISTINCT user_id) AS total_iphone_buyers
    FROM purchase_sequence
    WHERE product_name = 'iPhone'
),

iphone_then_airpod AS (
    SELECT COUNT(DISTINCT user_id) AS airpod_after_iphone
    FROM purchase_sequence
    WHERE product_name = 'iPhone'
      AND next_product = 'AirPods'
)

SELECT
    ROUND(
        100.0 * a.airpod_after_iphone / i.total_iphone_buyers,
    2) AS airpod_after_iphone_pct
FROM iphone_then_airpod a, iphone_buyers i;

SOLVED EXAMPLE β€” Q24: Rows where amount grew vs previous transaction

sql
WITH ordered_transactions AS (
    SELECT
        user_id,
        transaction_date,
        amount,
        LAG(amount) OVER (
            PARTITION BY user_id
            ORDER BY transaction_date
        ) AS prev_amount
    FROM transactions
)

SELECT user_id, transaction_date, amount, prev_amount
FROM ordered_transactions
WHERE prev_amount IS NOT NULL   -- skip first row per user
  AND amount > prev_amount;     -- only rows where amount increased

PATTERN 3 QUESTIONS

Q#QuestionKey Insight

Q17: Duplicate payments within 10 min (Stripe)

Approach: LAG with PARTITION BY merchant+card+amount

Q18: Avg delay between sign-up and 2nd ride

Approach: ROW_NUMBER to find 2nd ride, then DATEDIFF

Q19: Twitter 3-day rolling average

Approach: AVG OVER ROWS BETWEEN 2 PRECEDING AND CURRENT

Q20: AirPod after iPhone purchase (Apple)

Approach: LEAD to find next product, filter iPhone→AirPods

Q21: Countries moving up in comment ranking

Approach: Two CTEs (month1, month2) + compare ranks

Q22: 3 largest MoM call declines by company

Approach: LAG on monthly call count, ORDER BY decline

Q23: 2nd purchase within 48 hours of 1st

Approach: ROW_NUMBER filter rn IN (1,2) per user + DATEDIFF

Q24: Rows where purchase amount grew vs prev

Approach: LAG(amount) per user, filter WHERE amount > prev

⚠️ COMMON TRAPS ACROSS ALL WINDOW FUNCTIONS

🧠 Memory Map
TRAP 1: Forgetting PARTITION BY
Wrong: ROW_NUMBER() OVER (ORDER BY salary DESC)
β†’ ranks ALL employees globally, not per department!
Right: ROW_NUMBER() OVER (PARTITION BY dept_id ORDER BY salary DESC)
TRAP 2: Aggregate then window (ORDER OF OPERATIONS)
Wrong: SELECT SUM(amount) OVER (ORDER BY date), SUM(amount) FROM t GROUP BY date
β†’ can't mix window + group in same SELECT level
Right: Use CTE β€” aggregate in inner CTE, apply window in outer query
TRAP 3: RANK vs DENSE_RANK for "Top N"
"Find top 2 products" with RANK: if 2nd and 3rd are tied→both rank 2 but 3rd ranks 4 → 3rd excluded!
Use DENSE_RANK: both tied 2nd rank as 2β†’both included in "top 2" β†’ CORRECT!
TRAP 4: NULL in LAG/LEAD first/last row
First row for each partition: LAG() returns NULL (no previous row)
Must handle: COALESCE(LAG(col), 0) OR add WHERE prev_val IS NOT NULL
TRAP 5: Window function in WHERE clause
Wrong: WHERE ROW_NUMBER() OVER (...) = 1 ← SYNTAX ERROR
Right: Wrap in CTE, then WHERE rn = 1 in outer query
Advanced

Hard SQL Patterns

#

Hard SQL Patterns

SQL Patterns 4–6: Gaps & Islands, Sessionization, Deduplication

These are the patterns that separate average SQL writers from senior engineers. Gaps & Islands is THE hardest commonly-asked pattern. Master it.

🧠 MEMORY MAP

🧠 HARD PATTERNS = "GSD"
HARD PATTERNS"GSD"
GGaps & Islands: find consecutive sequences (date - ROW_NUMBER = constant!)
SSessionization: group events by inactivity gap (LAG + cumulative SUM)
DDeduplication: keep one row per key (ROW_NUMBER = 1)
GAPS & ISLANDS KEY TRICK:
If dates are CONSECUTIVE→(date - ROW_NUMBER) = SAME VALUE for the whole group!
When the sequence BREAKS→the (date - ROW_NUMBER) VALUE CHANGES
Group by that constant→each "island" becomes one group!
date row_num date - row_num = group_key
2024-01-01 1 2024-01-01 - 1 = 2023-12-31 ← island 1
2024-01-02 2 2024-01-02 - 2 = 2023-12-31 ← island 1 (same!)
2024-01-03 3 2024-01-03 - 3 = 2023-12-31 ← island 1 (same!)
← GAP: 01-04 missing β†’
2024-01-05 4 2024-01-05 - 4 = 2024-01-01 ← island 2 (different!)
2024-01-06 5 2024-01-06 - 5 = 2024-01-01 ← island 2 (same!)

Answer First: Consecutive values share an island key when an ordered sequence number is subtracted from the date or number.

Memory Map: Deduplicate -> ROW_NUMBER -> value minus sequence -> group island -> MIN/MAX/COUNT.

PATTERN 4: GAPS & ISLANDS

What Is It?

Find consecutive sequences ("islands") of events/dates, or the gaps between them. Classic use: login streaks, consecutive active days, periods of server uptime, stocks with N consecutive price increases.

Recognize It When You See:

  • "Consecutive days / weeks"
  • "Continuous period of [status]"
  • "Longest streak of [activity]"
  • "Start and end date of each [active/inactive] period"
  • "N or more consecutive [events]"
  • "Periods of server uptime/downtime"

THE TEMPLATE

sql
-- TEMPLATE: Gaps & Islands
-- Find all consecutive date sequences per user/entity

WITH activity AS (
    -- Step 1: Get one row per user per active date (deduplicate if needed)
    SELECT DISTINCT
        user_id,
        activity_date
    FROM events
),

numbered AS (
    -- Step 2: Assign row number PER USER ordered by date
    SELECT
        user_id,
        activity_date,
        ROW_NUMBER() OVER (
            PARTITION BY user_id
            ORDER BY activity_date
        ) AS rn
    FROM activity
),

islands AS (
    -- Step 3: The KEY TRICK β€” subtract row number from date
    -- Consecutive dates β†’ same (date - rn) value = same island group
    SELECT
        user_id,
        activity_date,
        rn,
        -- This creates a constant "group key" for each consecutive run:
        (activity_date - INTERVAL '1 day' * (rn - 1)) AS island_group
        -- In Hive: DATE_SUB(activity_date, rn - 1) AS island_group
        -- In Spark: DATE_SUB(activity_date, rn - 1) AS island_group
    FROM numbered
)

-- Step 4: Aggregate each island
SELECT
    user_id,
    MIN(activity_date) AS streak_start,
    MAX(activity_date) AS streak_end,
    COUNT(*) AS streak_length
FROM islands
GROUP BY user_id, island_group
ORDER BY user_id, streak_start;

SOLVED EXAMPLE β€” Q25 (StrataScratch Hard): Top 3 users by longest login streak

Problem:
Table: logins (user_id, login_date)
Find the top 3 users with the longest continuous login streak (no missing days).
Return: user_id, streak_start, streak_end, streak_length
sql
WITH deduplicated AS (
    -- Remove duplicate logins on same day for same user
    SELECT DISTINCT user_id, login_date
    FROM logins
),

numbered AS (
    SELECT
        user_id,
        login_date,
        ROW_NUMBER() OVER (
            PARTITION BY user_id
            ORDER BY login_date
        ) AS rn
    FROM deduplicated
),

islands AS (
    SELECT
        user_id,
        login_date,
        -- THE TRICK: consecutive dates minus sequential row numbers = constant
        (login_date - CAST(rn AS INT)) AS island_key
        -- Postgres: login_date - rn  (integer subtraction from date)
        -- Hive/Spark: DATE_SUB(login_date, rn)
    FROM numbered
),

streaks AS (
    SELECT
        user_id,
        MIN(login_date) AS streak_start,
        MAX(login_date) AS streak_end,
        COUNT(*) AS streak_length
    FROM islands
    GROUP BY user_id, island_key
),

ranked_streaks AS (
    SELECT
        user_id,
        streak_start,
        streak_end,
        streak_length,
        RANK() OVER (ORDER BY streak_length DESC) AS streak_rank
    FROM streaks
)

SELECT user_id, streak_start, streak_end, streak_length
FROM ranked_streaks
WHERE streak_rank <= 3
ORDER BY streak_length DESC;

SOLVED EXAMPLE β€” Q29: Stocks with 3+ consecutive days of price increases

Problem:
Table: stock_prices (stock_symbol, price_date, closing_price)
Find all stocks that had consecutive days of price INCREASES for at least 3 days.
Return: stock_symbol, start_date, end_date, streak_length
sql
WITH price_direction AS (
    SELECT
        stock_symbol,
        price_date,
        closing_price,
        LAG(closing_price) OVER (
            PARTITION BY stock_symbol
            ORDER BY price_date
        ) AS prev_price,
        -- Flag: 1 if price increased from yesterday, 0 otherwise
        CASE WHEN closing_price > LAG(closing_price) OVER (
            PARTITION BY stock_symbol ORDER BY price_date
        ) THEN 1 ELSE 0 END AS is_increase
    FROM stock_prices
),

increases_only AS (
    -- Keep only the increasing-price rows
    SELECT stock_symbol, price_date
    FROM price_direction
    WHERE is_increase = 1
),

numbered AS (
    SELECT
        stock_symbol,
        price_date,
        ROW_NUMBER() OVER (PARTITION BY stock_symbol ORDER BY price_date) AS rn
    FROM increases_only
),

islands AS (
    SELECT
        stock_symbol,
        price_date,
        (price_date - CAST(rn AS INT)) AS island_key
    FROM numbered
),

streaks AS (
    SELECT
        stock_symbol,
        MIN(price_date) AS streak_start,
        MAX(price_date) AS streak_end,
        COUNT(*) AS streak_length
    FROM islands
    GROUP BY stock_symbol, island_key
)

SELECT stock_symbol, streak_start, streak_end, streak_length
FROM streaks
WHERE streak_length >= 3
ORDER BY streak_length DESC;

PATTERN 4 QUESTIONS

Q#QuestionKey Insight

Q25: Top 3 users with longest login streak

Approach: Full gaps & islands + RANK on streak_length

Q26: Employees who worked consecutive days 5+ in a row

Approach: island_group on work_date, filter streak >= 5

Q27: Longest gap between orders per supplier

Approach: LEAD to get next_order, subtract dates, find max gap

Q28: Users who ordered every week for 4+ consecutive weeks

Approach: DATE_TRUNC('week') as the unit, then gaps & islands

Q29: Stocks with 3+ consecutive days of price increases

Approach: Add price_direction flag first, then gaps & islands on increases

Q30: Date ranges when server was continuously online

Approach: Gaps & islands on status='up' rows

Q31: Users with 30+ day inactivity gaps in history

Approach: LEAD to find next event, filter gap > 30 days

Answer First: A session starts when the gap from the previous event exceeds the threshold; a cumulative sum of start flags assigns session IDs.

Memory Map: LAG time -> gap -> new-session flag -> running SUM -> session aggregate.

PATTERN 5: SESSIONIZATION

What Is It?

Group raw event logs into sessions. A new session starts when a user has been inactive for more than a threshold (e.g., 30 minutes). Every event gets a session_id.

Recognize It When You See:

  • "Group events into sessions"
  • "Session = no activity for more than X minutes"
  • "Count number of sessions per user"
  • "Average session duration"
  • "Did a conversion happen within the same session?"

THE TEMPLATE

sql
-- TEMPLATE: Sessionization (create session_id from events)

WITH events_with_prev AS (
    SELECT
        user_id,
        event_time,
        event_type,
        -- Get the previous event time for this user
        LAG(event_time) OVER (
            PARTITION BY user_id
            ORDER BY event_time
        ) AS prev_event_time
    FROM events
),

session_flags AS (
    SELECT
        user_id,
        event_time,
        event_type,
        -- Flag: 1 = start of a NEW session (gap > 30 min OR first event)
        CASE
            WHEN prev_event_time IS NULL THEN 1                       -- first event ever
            WHEN event_time - prev_event_time > INTERVAL '30 minutes' THEN 1  -- gap > threshold
            ELSE 0
        END AS is_new_session
    FROM events_with_prev
),

sessions AS (
    SELECT
        user_id,
        event_time,
        event_type,
        is_new_session,
        -- Cumulative sum of new_session flags = session_id per user!
        SUM(is_new_session) OVER (
            PARTITION BY user_id
            ORDER BY event_time
            ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
        ) AS session_id
    FROM session_flags
)

-- Now you have session_id on every event. Use it for aggregation:
SELECT
    user_id,
    session_id,
    MIN(event_time) AS session_start,
    MAX(event_time) AS session_end,
    COUNT(*) AS events_in_session,
    MAX(event_time) - MIN(event_time) AS session_duration
FROM sessions
GROUP BY user_id, session_id
ORDER BY user_id, session_id;

SOLVED EXAMPLE β€” Q32: Assign session_id (new session = 30 min inactivity)

Problem:
Table: user_events (user_id, event_id, event_time, event_type)
Assign a session_id to each event.
New session starts when user has been inactive for > 30 minutes.
sql
WITH events_ordered AS (
    SELECT
        user_id,
        event_id,
        event_time,
        event_type,
        LAG(event_time) OVER (
            PARTITION BY user_id
            ORDER BY event_time
        ) AS prev_event_time
    FROM user_events
),

new_session_flags AS (
    SELECT
        user_id,
        event_id,
        event_time,
        event_type,
        CASE
            WHEN prev_event_time IS NULL THEN 1
            WHEN EXTRACT(EPOCH FROM (event_time - prev_event_time)) / 60 > 30 THEN 1
            -- Hive/Spark: (UNIX_TIMESTAMP(event_time) - UNIX_TIMESTAMP(prev_event_time)) / 60 > 30
            ELSE 0
        END AS new_session
    FROM events_ordered
)

SELECT
    user_id,
    event_id,
    event_time,
    event_type,
    SUM(new_session) OVER (
        PARTITION BY user_id
        ORDER BY event_time
        ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
    ) AS session_id   -- starts at 1 for first session, increments at each new session
FROM new_session_flags
ORDER BY user_id, event_time;

PATTERN 5 QUESTIONS

Q#QuestionKey Insight

Q32: Assign session_id (30 min gap = new session)

Approach: LAG + CASE WHEN gap > threshold + cumulative SUM

Q33: Average session duration per user

Approach: Sessionize first, then AVG(session_end - session_start)

Q34: Users with most sessions in a month

Approach: Sessionize, then COUNT(DISTINCT session_id) per user per month

Q35: Sessions that resulted in a purchase

Approach: Sessionize, then LEFT JOIN purchase events within session

Q36: Viewers who later became streamers

Approach: ROW_NUMBER to find first session type per user

Answer First: Deduplicate with ROW_NUMBER over the business key and a deterministic preference order, keeping row 1.

Memory Map: Business key -> ORDER BY recency/quality plus tie-breaker -> ROW_NUMBER -> filter rn = 1.

PATTERN 6: DEDUPLICATION

What Is It?

Remove duplicate rows from a table, keeping only one record per key. Critical for ETL pipelines, CDC (Change Data Capture) processing, and data quality checks.

Recognize It When You See:

  • "Remove duplicates / deduplicate"
  • "Keep only the latest/most recent record"
  • "CDC table β€” keep current state of each record"
  • "ETL bug created duplicate rows"
  • "Find rows that appear more than once"

THE TEMPLATE

sql
-- TEMPLATE: Deduplication β€” keep most recent record per key
WITH ranked AS (
    SELECT
        *,
        ROW_NUMBER() OVER (
            PARTITION BY unique_key_col     -- "what defines a duplicate?"
            ORDER BY updated_at DESC        -- "which one to keep?" (DESC = latest first)
        ) AS rn
    FROM source_table
)

SELECT * EXCEPT (rn)   -- or SELECT col1, col2... (exclude the rn column)
FROM ranked
WHERE rn = 1;          -- keep only the first row (= latest record) per key

-- ⚠️ ROW_NUMBER always gives unique ranks (no ties)
-- β†’ exactly 1 row per partition will have rn = 1
-- ⚠️ RANK() could give multiple rows with rank=1 if tie on updated_at β†’ unsafe for dedup

SOLVED EXAMPLE β€” Q38 (Databricks/DE): CDC dedup β€” keep most recent per customer

Problem:
Table: customer_cdc (customer_id, name, email, address, modified_at)
This is a CDC (change data capture) table where every UPDATE creates a new row.
Keep only the most recent record per customer_id.
sql
WITH latest_record AS (
    SELECT
        customer_id,
        name,
        email,
        address,
        modified_at,
        ROW_NUMBER() OVER (
            PARTITION BY customer_id
            ORDER BY modified_at DESC    -- latest timestamp first β†’ rn=1 is most recent
        ) AS rn
    FROM customer_cdc
)

SELECT customer_id, name, email, address, modified_at
FROM latest_record
WHERE rn = 1;

-- ⚠️ In production Databricks/Delta Lake:
-- MERGE INTO customer_dim USING (SELECT ... WHERE rn=1) ON customer_id MATCH
-- This is the SCD Type 1 (overwrite) pattern

SOLVED EXAMPLE β€” Q40: Users with more than one account (same email, different user_ids)

sql
-- Approach 1: Find the emails that have duplicates
SELECT
    email,
    COUNT(DISTINCT user_id) AS account_count
FROM users
GROUP BY email
HAVING COUNT(DISTINCT user_id) > 1;

-- Approach 2: Return ALL user_ids that share an email with another user
SELECT u.user_id, u.email
FROM users u
WHERE u.email IN (
    SELECT email
    FROM users
    GROUP BY email
    HAVING COUNT(DISTINCT user_id) > 1
)
ORDER BY u.email, u.user_id;

PATTERN 6 QUESTIONS

Q#QuestionKey Insight

Q37: Count how many times each customer_id is duplicated

Approach: GROUP BY + HAVING COUNT > 1

Q38: CDC table β€” keep most recent record per customer

Approach: ROW_NUMBER PARTITION BY id ORDER BY modified_at DESC, keep rn=1

Q39: Duplicate transaction_ids β€” keep highest amount record

Approach: ROW_NUMBER PARTITION BY txn_id ORDER BY amount DESC

Q40: Users with multiple accounts (same email)

Approach: GROUP BY email HAVING COUNT(DISTINCT user_id) > 1

Q41: Deduplicate user_profiles keeping lowest user_id per email

Approach: ROW_NUMBER PARTITION BY email ORDER BY user_id ASC, keep rn=1

⚠️ COMMON TRAPS IN HARD PATTERNS

🧠 Memory Map
GAPS & ISLANDS TRAPS:
TRAP 1: Forgetting to DISTINCT first
Same user can have multiple events on same day!
Always: SELECT DISTINCT user_id, activity_date BEFORE the ROW_NUMBER step
Otherwise: two events on 2024-01-01 give rn=1 and rn=2
β†’ dates - row numbers are different even though dates are consecutive!
TRAP 2: Integer vs Date subtraction syntax
PostgreSQL: date_col - rn (date - integer = date, works directly)
Hive/Spark: DATE_SUB(date_col, rn)
MySQL: DATE_SUB(date_col, INTERVAL rn DAY)
TRAP 3: Weekly streaks need DATE_TRUNC first
For "consecutive WEEKS" (not days): DATE_TRUNC('week', date) as the unit
Then gaps & islands on the week-truncated date
SESSIONIZATION TRAPS
TRAP 4: Hive/Spark UNIX_TIMESTAMP for time differences
PostgreSQL: event_time - prev_event_time > INTERVAL '30 minutes'
Hive/Spark: (UNIX_TIMESTAMP(event_time) - UNIX_TIMESTAMP(prev_event_time)) / 60 > 30
TRAP 5: Session_id starts at 1 automatically
SUM(new_session_flag) starting from 1 (not 0) because first event has new_session=1
So session_id = 1 for first session→good!
DEDUPLICATION TRAPS
TRAP 6: RANK() vs ROW_NUMBER() for dedup
RANK() with ties: multiple rows can have rank=1 β†’ keeps ALL tied rows!
ROW_NUMBER()β†’always exactly ONE row per partition β†’ safe for dedup
Intermediate

SQL Join Patterns

#

SQL Join Patterns

SQL Patterns 7–9: Self-Join, Cross Join, Market Basket

πŸ’‘ Insight
Join-based patterns. The key insight: SQL can join a table to ITSELF. Once you understand this, a whole category of questions becomes easy.

🧠 MEMORY MAP

🧠 JOIN PATTERNS = "SCM"
JOIN PATTERNS"SCM"
SSelf-Join: same table twice (hierarchy, pairs, comparisons)
CCross Join: every row Γ— every row (combinations, grids)
MMarket Basket: self-join on order_id (items bought together)
SELF-JOIN KEY RULE:
Use TWO aliases for the same table: FROM employees e1 JOIN employees e2
The JOIN condition defines the RELATIONSHIP between the two copies:
β†’ e2.manager_id = e1.id (hierarchy)
β†’ e1.score - e2.score <= 5 (similarity)
β†’ e1.item_id < e2.item_id (pairs, avoid duplicates)
CROSS JOIN RULE
N rows Γ— M rows = NΓ—M rows (Cartesian product)
Use only when you WANT every combination (grids, matchups, combinations)
⚠️Never accidentally CROSS JOIN large tables β†’ explodes data!
MARKET BASKET KEY RULE
Self-join order_items ON same order_id BUT different product
Use: o1.product_id < o2.product_id→prevents (A,B) AND (B,A) both appearing

Answer First: A self-join gives two roles to one table, which supports hierarchies, comparisons, and pair generation.

Memory Map: Name each role -> join relationship -> prevent self-pairs -> enforce one pair ordering.

PATTERN 7: SELF-JOIN

What Is It?

Join a table to itself to compare rows within the same table, traverse hierarchies, or find relationships between records of the same type.

Recognize It When You See:

  • "Employee and their manager" (same employees table)
  • "Find pairs that share [something]"
  • "Users who both attended [same event]"
  • "Salary higher than [their own manager]"
  • "Students who scored within N points of each other"
  • "Players who beat [same opponent] twice"

THE TEMPLATE

sql
-- TEMPLATE: Self-Join (two aliases for same table)

-- Pattern A: Hierarchy (employee β†’ manager)
SELECT
    e.name AS employee_name,
    e.salary AS employee_salary,
    m.name AS manager_name,
    m.salary AS manager_salary
FROM employees e
JOIN employees m ON e.manager_id = m.employee_id   -- join condition defines relationship
WHERE e.salary > m.salary;                          -- filter condition

-- Pattern B: Finding pairs (avoid duplicates with < operator)
SELECT
    t1.id AS id1,
    t2.id AS id2,
    t1.col AS col1,
    t2.col AS col2
FROM table t1
JOIN table t2
    ON t1.shared_key = t2.shared_key    -- what links them
    AND t1.id < t2.id                   -- prevent (A,B) AND (B,A), prevent self-pairs
WHERE ABS(t1.value - t2.value) <= 5;   -- similarity condition

SOLVED EXAMPLE β€” Q42 (Amazon): Employees earning more than their manager

Problem:
Table: employees (employee_id, name, salary, manager_id)
manager_id references employee_id in the same table.
Find employees who earn MORE than their direct manager.
sql
SELECT
    e.name AS employee_name,
    e.salary AS employee_salary,
    m.name AS manager_name,
    m.salary AS manager_salary
FROM employees e
JOIN employees m
    ON e.manager_id = m.employee_id    -- link employee to their manager
WHERE e.salary > m.salary              -- filter: only employees earning more
ORDER BY e.salary DESC;

-- ⚠️ TRAP: Don't forget to handle NULLs!
--   Top-level managers have manager_id = NULL β†’ JOIN filters them out (correct!)
--   Use INNER JOIN β†’ rows without a manager are excluded automatically

SOLVED EXAMPLE β€” Q43 (Facebook): Friend recommendations via shared private events

Problem:
Table: event_rsvp (user_id, event_id, event_type)
Table: friendships (user_id_1, user_id_2)
Recommend friends: users who attended 2+ same PRIVATE events but aren't yet friends.
Return: user_id_1, user_id_2, shared_event_count
sql
WITH private_rsvp AS (
    -- Step 1: Only private events
    SELECT user_id, event_id
    FROM event_rsvp
    WHERE event_type = 'private'
),

shared_events AS (
    -- Step 2: Self-join to find users who attended same events
    SELECT
        r1.user_id AS user_1,
        r2.user_id AS user_2,
        COUNT(r1.event_id) AS shared_events
    FROM private_rsvp r1
    JOIN private_rsvp r2
        ON r1.event_id = r2.event_id      -- same event
        AND r1.user_id < r2.user_id        -- avoid (A,B) and (B,A), avoid self-pairs
    GROUP BY r1.user_id, r2.user_id
    HAVING COUNT(r1.event_id) >= 2         -- attended 2+ same events
),

existing_friends AS (
    -- Step 3: Normalize friendships table (ensure both directions covered)
    SELECT user_id_1 AS u1, user_id_2 AS u2 FROM friendships
    UNION
    SELECT user_id_2, user_id_1 FROM friendships
)

-- Step 4: Exclude pairs who are already friends
SELECT s.user_1, s.user_2, s.shared_events
FROM shared_events s
LEFT JOIN existing_friends f
    ON s.user_1 = f.u1 AND s.user_2 = f.u2
WHERE f.u1 IS NULL;   -- not already friends

PATTERN 7 QUESTIONS

Q#QuestionKey Insight

Q42: Employees earning more than their manager

Approach: e JOIN e ON e.manager_id = m.id, WHERE e.salary > m.salary

Q43: Friend recommendations via shared events

Approach: Self-join events + HAVING count >= 2 + exclude existing friends

Q44: Student pairs in same class within 5 score points

Approach: Self-join ON same class + ABS(s1.score - s2.score) <= 5

Q45: Cheapest two-stop flight routes

Approach: Self-join flights ON f1.destination = f2.origin

Q46: Players who beat same opponent at least twice

Approach: Self-join matches + GROUP BY player + HAVING COUNT >= 2

Q47: Managers with 5+ direct reports

Approach: GROUP BY manager_id + HAVING COUNT(*) >= 5 (no self-join needed!)

Answer First: CROSS JOIN creates the Cartesian product; constrain it when the task asks for unique unordered combinations.

Memory Map: Set A x set B -> expected row count -> exclude self-pairs -> enforce a.id < b.id when symmetric.

PATTERN 8: CROSS JOIN β€” COMBINATION GENERATION

What Is It?

Generate every possible combination of rows from two tables. The result has N Γ— M rows (Cartesian product). Used deliberately for: grid creation, tournament schedules, filling coverage gaps.

Recognize It When You See:

  • "All possible combinations of X and Y"
  • "All matchups / every team plays every other team"
  • "Generate all size-color combinations"
  • "Fill in missing date Γ— product combinations"
  • "Find zero-sales days per product"

THE TEMPLATE

sql
-- TEMPLATE A: Simple combination generation
SELECT
    a.col1,
    b.col2
FROM table_a a
CROSS JOIN table_b b;   -- generates every row of a paired with every row of b

-- TEMPLATE B: Self-cross-join for pairs (e.g., tournament matchups)
SELECT
    t1.team_name AS home_team,
    t2.team_name AS away_team
FROM teams t1
CROSS JOIN teams t2
WHERE t1.team_id < t2.team_id;   -- prevents (A vs B) AND (B vs A), prevents self-matches

-- TEMPLATE C: Fill gaps β€” generate full grid then LEFT JOIN to actuals
WITH all_dates AS (
    SELECT generate_series(
        '2024-01-01'::date,
        '2024-12-31'::date,
        '1 day'::interval
    )::date AS date_val
    -- Hive: no recursive CTE; use a prebuilt date dimension or generate dates in ETL
    -- Spark 4.1+: recursive CTE is available; `EXPLODE(SEQUENCE(...))` works without recursion
),
all_products AS (SELECT DISTINCT product_id FROM products)

SELECT
    d.date_val,
    p.product_id,
    COALESCE(s.revenue, 0) AS revenue   -- 0 for days with no sales
FROM all_dates d
CROSS JOIN all_products p
LEFT JOIN daily_sales s
    ON d.date_val = s.sale_date
    AND p.product_id = s.product_id;

SOLVED EXAMPLE β€” Q51 (McKinsey): All 3-topping pizza combinations with total cost

Problem:
Table: toppings (topping_name, ingredient_cost)
Generate all possible 3-topping combinations.
Return: topping1, topping2, topping3, total_cost (sorted alphabetically).
No topping can be used twice in a combination.
sql
SELECT
    t1.topping_name AS topping_1,
    t2.topping_name AS topping_2,
    t3.topping_name AS topping_3,
    t1.ingredient_cost + t2.ingredient_cost + t3.ingredient_cost AS total_cost
FROM toppings t1
JOIN toppings t2 ON t1.topping_name < t2.topping_name   -- ensures t1 < t2 < t3 alphabetically
JOIN toppings t3 ON t2.topping_name < t3.topping_name   -- avoids all duplicate combos
ORDER BY total_cost, topping_1, topping_2, topping_3;

-- ⚠️ Using < on the name column (lexicographic order) ensures:
--   (Basil, Mushroom, Peppers) appears but NOT (Mushroom, Basil, Peppers)
--   This gives exactly C(n,3) = n!/3!(n-3)! unique combinations

PATTERN 8 QUESTIONS

Q#QuestionKey Insight

Q48: All size-color combinations

Approach: CROSS JOIN sizes Γ— colors

Q49: Full date Γ— product grid for zero-fill reporting

Approach: CROSS JOIN dates Γ— products + LEFT JOIN sales

Q50: Round-robin tournament schedule

Approach: CROSS JOIN teams WHERE t1.id < t2.id

Q51: 3-topping pizza combinations with cost

Approach: Three-way JOIN with t1 < t2 < t3

Q52: Region Γ— category grid for monthly report

Approach: CROSS JOIN regions Γ— categories + LEFT JOIN actuals

Answer First: Market-basket analysis self-joins items within the same order and counts each canonical item pair once.

Memory Map: Same basket -> item1 < item2 -> distinct basket count -> rank pairs -> optionally compute support or lift.

PATTERN 9: MARKET BASKET / PRODUCT PAIRS

What Is It?

Find items (products, events, attributes) that frequently appear TOGETHER in the same transaction/order/session. The secret weapon: self-join on the grouping key (order_id).

Recognize It When You See:

  • "Products/items frequently bought together"
  • "Most common pair in same order"
  • "Items purchased in the same transaction"
  • "Co-occurrence count"
  • "Lift / confidence / association rules"

THE TEMPLATE

sql
-- TEMPLATE: Market Basket β€” find co-occurring item pairs

WITH item_pairs AS (
    SELECT
        o1.product_id AS product_1,
        o2.product_id AS product_2,
        COUNT(DISTINCT o1.order_id) AS co_purchase_count
    FROM order_items o1
    JOIN order_items o2
        ON o1.order_id = o2.order_id        -- same order
        AND o1.product_id < o2.product_id    -- avoid (A,B) AND (B,A), avoid (A,A)
    GROUP BY o1.product_id, o2.product_id
    ORDER BY co_purchase_count DESC
)

SELECT
    p1.product_name AS product_1,
    p2.product_name AS product_2,
    ip.co_purchase_count
FROM item_pairs ip
JOIN products p1 ON ip.product_1 = p1.product_id
JOIN products p2 ON ip.product_2 = p2.product_id
ORDER BY co_purchase_count DESC
LIMIT 5;   -- top 5 pairs

SOLVED EXAMPLE β€” Q53 (Interview Query Hard): Top 5 product pairs bought together

Problem:
Table: transactions (order_id, user_id, product_id, quantity)
Table: products (product_id, product_name)
Find the 5 product pairs most frequently purchased in the same order.
Return: product1, product2, purchase_count
sql
WITH product_pairs AS (
    SELECT
        t1.product_id AS product_1_id,
        t2.product_id AS product_2_id,
        COUNT(DISTINCT t1.order_id) AS times_bought_together
    FROM transactions t1
    JOIN transactions t2
        ON t1.order_id = t2.order_id        -- same order
        AND t1.product_id < t2.product_id    -- canonical pair, no duplicates
    GROUP BY t1.product_id, t2.product_id
)

SELECT
    p1.product_name AS product_1,
    p2.product_name AS product_2,
    pp.times_bought_together
FROM product_pairs pp
JOIN products p1 ON pp.product_1_id = p1.product_id
JOIN products p2 ON pp.product_2_id = p2.product_id
ORDER BY pp.times_bought_together DESC
LIMIT 5;

SOLVED EXAMPLE β€” Q55: Product pairs with co-purchase > 100 and lift score

Problem:
Table: orders (order_id, product_id)
Find product pairs where co-purchase count > 100.
Also compute LIFT = pair_frequency / (freq_A Γ— freq_B)
(Lift > 1 means the pair appears MORE than expected by chance)
sql
WITH total_orders AS (
    SELECT COUNT(DISTINCT order_id) AS total FROM orders
),

product_freq AS (
    -- How often does each product appear in ANY order?
    SELECT product_id, COUNT(DISTINCT order_id) AS freq
    FROM orders
    GROUP BY product_id
),

pair_freq AS (
    -- How often do pairs appear in the SAME order?
    SELECT
        o1.product_id AS prod_1,
        o2.product_id AS prod_2,
        COUNT(DISTINCT o1.order_id) AS pair_count
    FROM orders o1
    JOIN orders o2
        ON o1.order_id = o2.order_id
        AND o1.product_id < o2.product_id
    GROUP BY o1.product_id, o2.product_id
    HAVING COUNT(DISTINCT o1.order_id) > 100
)

SELECT
    pf.prod_1,
    pf.prod_2,
    pf.pair_count,
    -- LIFT = (pair_count / total) / ((freq_A / total) Γ— (freq_B / total))
    --      = pair_count Γ— total / (freq_A Γ— freq_B)
    ROUND(
        pf.pair_count * t.total * 1.0 / (f1.freq * f2.freq),
    3) AS lift
FROM pair_freq pf
JOIN product_freq f1 ON pf.prod_1 = f1.product_id
JOIN product_freq f2 ON pf.prod_2 = f2.product_id
CROSS JOIN total_orders t
ORDER BY lift DESC;

-- Lift interpretation:
-- > 1.0: products bought together MORE than expected β†’ strong association
-- = 1.0: products bought together exactly as expected β†’ no association
-- < 1.0: products bought together LESS than expected β†’ negative association

PATTERN 9 QUESTIONS

Q#QuestionKey Insight

Q53: Top 5 product pairs most frequently bought together

Approach: Self-join ON order_id, prod1 < prod2, GROUP BY + LIMIT

Q54: Product most commonly bought alongside Product X

Approach: Same self-join but filter one side = Product X

Q55: Pairs where co-purchase > 100 + lift score

Approach: Add product_freq and total_orders CTEs for lift calculation

Q56: Menu items ordered together > 30% of the time

Approach: confidence = pair_count / item_count; filter HAVING conf > 0.3

⚠️ COMMON TRAPS IN JOIN PATTERNS

sql
SELF-JOIN TRAPS:

TRAP 1: Forgetting id < id2 β†’ duplicate pairs
  Without: SELECT * FROM t a JOIN t b ON a.shared = b.shared
  β†’ Returns (A,B) AND (B,A) and (A,A) β†’ pairs are doubled + self-pairs!
  With:    AND a.id < b.id β†’ each pair appears exactly ONCE, no self-pairs

TRAP 2: Manager NULL handling
  Top-level employees have manager_id = NULL
  Inner JOIN on manager_id excludes them (good for "emp earns more than manager")
  Use LEFT JOIN if you want to return ALL employees including top-level

CROSS JOIN TRAPS:

TRAP 3: Accidental cross join on large tables
  FROM table1, table2  ← old syntax for CROSS JOIN (comma = cross join!)
  If you meant to write a regular JOIN and forgot the ON clause β†’ massive data explosion
  Always: write CROSS JOIN explicitly when you mean it, or INNER/LEFT JOIN with ON clause

MARKET BASKET TRAPS:

TRAP 4: COUNT(order_id) vs COUNT(DISTINCT order_id)
  If same product appears twice in one order (quantity > 1), order_id appears twice
  Use COUNT(DISTINCT order_id) to count ORDERS (not line items)
  Use COUNT(*) if you want to count total line item co-occurrences

TRAP 5: Performance on large order tables
  Self-join on order_items can explode: 10M rows Γ— 10M rows = 100 trillion pairs!
  Always add PARTITION to narrow the self-join (e.g., same date or same user)
  Or: pre-filter to only frequent products before the self-join
Advanced

SQL Aggregation and Cohort Analysis

#

SQL Aggregation and Cohort Analysis

SQL Patterns 10–12: Conditional Aggregation, Cohort/Retention, Date Arithmetic

These patterns appear in almost every analytics and data engineering role. Cohort analysis is the most frequently asked "hard" business SQL question.

🧠 MEMORY MAP

🧠 ANALYTICS PATTERNS = "CAD"
ANALYTICS PATTERNS"CAD"
CConditional Aggregation: SUM(CASE WHEN ...) = pivot/segment in one query
AcAhort/Retention: MIN(event_date) as cohort key + offset joins
DDate Arithmetic: DATEDIFF, DATE_TRUNC, INTERVAL for time-based calculations
CONDITIONAL AGGREGATION RULE
"Count/Sum by category WITHOUT multiple queries"
SUM(CASE WHEN category='A' THEN 1 ELSE 0 END) AS count_A
SUM(CASE WHEN category='B' THEN amount ELSE 0 END) AS revenue_B
COHORT RULE: "C-M-R" (Cohort β†’ Measure β†’ Rate)
CCohort: MIN(event_date) per user = their "birth date" in this system
MMeasure: COUNT(users who returned on day/week N after cohort date)
RRate: returned_count / cohort_size Γ— 100
DATE ARITHMETIC RULE: "DIFF-TRUNC-ADD"
DIFFDATEDIFF(end, start) for age/interval
TRUNCDATE_TRUNC('month', date) for grouping by period
ADDDATE_ADD(date, 7) for deadlines and offsets

Answer First: Conditional aggregation converts row categories into measures with CASE inside SUM, COUNT, or AVG.

Memory Map: Group grain -> CASE predicates -> aggregate -> zero/NULL policy -> NULLIF ratio denominator.

PATTERN 10: CONDITIONAL AGGREGATION / PIVOT

What Is It?

Count or sum values BY CONDITION within a single row using CASE WHEN inside aggregate functions. Creates a "wide" result with category-based columns.

Recognize It When You See:

  • "Show [metric] broken down by [category] in the same row"
  • "Count paying vs non-paying users per date"
  • "Revenue by quarter as columns"
  • "Click-through rate (clicks / impressions)"
  • "Success vs failure count side by side"
  • "Pivot [categories] into columns"

THE TEMPLATE

sql
-- TEMPLATE: Conditional Aggregation
SELECT
    grouping_col,                    -- row identifier (date, user, region)

    -- Count rows matching a condition
    COUNT(CASE WHEN category = 'A' THEN 1 END)          AS count_a,
    SUM(CASE WHEN category = 'A' THEN 1 ELSE 0 END)     AS count_a_v2,  -- same result

    -- Sum values matching a condition
    SUM(CASE WHEN category = 'B' THEN amount ELSE 0 END) AS total_b,

    -- Ratio / percentage
    ROUND(
        100.0 * SUM(CASE WHEN event = 'click' THEN 1 ELSE 0 END)
        / NULLIF(SUM(CASE WHEN event = 'impression' THEN 1 ELSE 0 END), 0),
    2) AS ctr_pct,

    -- Average conditional
    AVG(CASE WHEN is_premium = 1 THEN rating END)        AS avg_premium_rating

FROM source_table
GROUP BY grouping_col;

-- ⚠️ NULLIF(denominator, 0) prevents division-by-zero error
-- ⚠️ COUNT(CASE WHEN ...) counts NULL as 0 β€” CASE WHEN false returns NULL, not counted
--    SUM(CASE WHEN ... THEN 1 ELSE 0) is safer (explicit 0 for false)

SOLVED EXAMPLE β€” Q57 (Microsoft): Downloads for paying vs non-paying, filter where non > paying

Problem:
Table: user_activity (date, user_id, downloads)
Table: paying_users (user_id)
Show total downloads per day for paying vs non-paying users.
Only include dates where non-paying downloads > paying downloads.
sql
WITH daily_downloads AS (
    SELECT
        ua.date,
        SUM(CASE WHEN pu.user_id IS NOT NULL THEN ua.downloads ELSE 0 END)
            AS paying_downloads,
        SUM(CASE WHEN pu.user_id IS NULL THEN ua.downloads ELSE 0 END)
            AS non_paying_downloads
    FROM user_activity ua
    LEFT JOIN paying_users pu ON ua.user_id = pu.user_id
    GROUP BY ua.date
)

SELECT date, paying_downloads, non_paying_downloads
FROM daily_downloads
WHERE non_paying_downloads > paying_downloads
ORDER BY date;

SOLVED EXAMPLE β€” Q58 (Facebook): Click-through rate per app in 2022

Problem:
Table: events (app_id, event_type, timestamp) -- event_type: 'click' or 'impression'
Calculate CTR = 100.0 Γ— clicks / impressions per app in 2022.
sql
SELECT
    app_id,
    ROUND(
        100.0
        * SUM(CASE WHEN event_type = 'click' THEN 1 ELSE 0 END)
        / NULLIF(SUM(CASE WHEN event_type = 'impression' THEN 1 ELSE 0 END), 0),
    2) AS ctr_percentage
FROM events
WHERE EXTRACT(YEAR FROM timestamp) = 2022
GROUP BY app_id
ORDER BY ctr_percentage DESC;

PATTERN 10 QUESTIONS

Q#QuestionKey Insight

Q57: Downloads paying vs non-paying, filter non > paying

Approach: LEFT JOIN to mark paying users + CASE WHEN

Q58: Click-through rate (clicks/impressions)

Approach: SUM(CASE WHEN 'click') / SUM(CASE WHEN 'impression') Γ— 100

Q59: Pivot monthly revenue by product category

Approach: SUM(CASE WHEN month='Jan') AS Jan, etc.

Q60: Users active exactly 3 of past 7 days

Approach: SUM(CASE WHEN recent date THEN 1) per user, HAVING = 3

Q61: Orders per quarter as separate columns

Approach: SUM(CASE WHEN EXTRACT(quarter)=1) for each quarter

Q62: Twitch streamer vs viewer session ratio

Approach: SUM(CASE WHEN type='streamer'), SUM(CASE WHEN type='viewer')

Answer First: Cohort analysis fixes each user to a starting cohort, measures later activity by offset, and divides retained users by cohort size.

Memory Map: First event -> cohort bucket -> activity offset -> distinct retained users -> cohort denominator.

PATTERN 11: COHORT / RETENTION ANALYSIS

What Is It?

Group users by when they first appeared (sign-up cohort). Measure what % of each cohort was still active after N days/weeks/months. Most important pattern for product analytics and growth-focused data roles.

Recognize It When You See:

  • "Retention rate / monthly retention"
  • "Day-1, Day-7, Day-30 retention"
  • "Returning customers vs first-time"
  • "Cohort [by signup month] [activity over time]"
  • "Monthly active users who were also active last month"

THE TEMPLATE

sql
-- TEMPLATE: Cohort Retention
-- Step 1: Find each user's cohort date (first event)
WITH user_cohorts AS (
    SELECT
        user_id,
        MIN(event_date) AS cohort_date,              -- first ever event = cohort date
        DATE_TRUNC('month', MIN(event_date)) AS cohort_month
    FROM events
    GROUP BY user_id
),

-- Step 2: Join all events back to cohort date to compute "days since cohort"
user_activity AS (
    SELECT
        e.user_id,
        uc.cohort_month,
        -- How many months after their cohort month is this event?
        DATEDIFF('month', uc.cohort_date, e.event_date) AS months_since_cohort
        -- Hive: MONTHS_BETWEEN(e.event_date, uc.cohort_date)
        -- Spark: DATEDIFF(e.event_date, uc.cohort_date) / 30 (approximate)
    FROM events e
    JOIN user_cohorts uc ON e.user_id = uc.user_id
),

-- Step 3: Count users active at each cohort offset
retention AS (
    SELECT
        cohort_month,
        months_since_cohort AS period_offset,
        COUNT(DISTINCT user_id) AS active_users
    FROM user_activity
    GROUP BY cohort_month, months_since_cohort
),

-- Step 4: Get cohort sizes (users in month 0 = all users in that cohort)
cohort_sizes AS (
    SELECT cohort_month, active_users AS cohort_size
    FROM retention
    WHERE period_offset = 0
)

-- Step 5: Compute retention rate per cohort per period
SELECT
    r.cohort_month,
    r.period_offset,
    r.active_users,
    cs.cohort_size,
    ROUND(100.0 * r.active_users / cs.cohort_size, 2) AS retention_rate_pct
FROM retention r
JOIN cohort_sizes cs ON r.cohort_month = cs.cohort_month
ORDER BY r.cohort_month, r.period_offset;

SOLVED EXAMPLE β€” Q63 (Facebook Hard): MAU July 2022 (active in BOTH June AND July)

Problem:
Table: user_actions (user_id, event_date, action_type)
Find count of monthly active users in July 2022.
MAU definition: active in BOTH June 2022 AND July 2022.
sql
WITH june_active AS (
    SELECT DISTINCT user_id
    FROM user_actions
    WHERE event_date >= '2022-06-01'
      AND event_date < '2022-07-01'
),

july_active AS (
    SELECT DISTINCT user_id
    FROM user_actions
    WHERE event_date >= '2022-07-01'
      AND event_date < '2022-08-01'
)

-- Users who were active in BOTH months = intersection
SELECT COUNT(*) AS monthly_active_users
FROM june_active ja
INNER JOIN july_active jul ON ja.user_id = jul.user_id;

-- Alternative with EXISTS:
SELECT COUNT(DISTINCT j.user_id) AS monthly_active_users
FROM july_active j
WHERE EXISTS (
    SELECT 1 FROM june_active ja WHERE ja.user_id = j.user_id
);

SOLVED EXAMPLE β€” Q65: Day-7 retention for January 2024 sign-ups

Problem:
Table: signups (user_id, signup_date)
Table: daily_logins (user_id, login_date)
Find: what % of users who signed up in January 2024 were active exactly on Day 7?
(Day 7 = signup_date + 7 days)
sql
WITH jan_signups AS (
    SELECT
        user_id,
        signup_date,
        signup_date + INTERVAL '7 days' AS day_7_date   -- their specific Day 7
        -- Hive/Spark: DATE_ADD(signup_date, 7) AS day_7_date
    FROM signups
    WHERE signup_date >= '2024-01-01'
      AND signup_date < '2024-02-01'
),

day7_active AS (
    -- Check if each user logged in on their Day 7
    SELECT
        js.user_id,
        MAX(CASE WHEN dl.login_date = js.day_7_date THEN 1 ELSE 0 END) AS was_active_day7
    FROM jan_signups js
    LEFT JOIN daily_logins dl
        ON js.user_id = dl.user_id
        AND dl.login_date = js.day_7_date
    GROUP BY js.user_id
)

SELECT
    COUNT(*) AS cohort_size,
    SUM(was_active_day7) AS day7_actives,
    ROUND(100.0 * SUM(was_active_day7) / COUNT(*), 2) AS day7_retention_rate
FROM day7_active;

PATTERN 11 QUESTIONS

Q#QuestionKey Insight

Q63: MAU July 2022 (active both June + July)

Approach: INTERSECT or JOIN on two sets of monthly active users

Q64: Retention rate for monthly cohort at month 1, 2, 3

Approach: DATEDIFF in months since cohort, then retention template

Q65: Day-7 retention for January sign-ups

Approach: signup_date + 7 as target date, LEFT JOIN logins

Q66: Weekly cohort: % making 2nd purchase within 30 days

Approach: DATE_TRUNC('week', first_purchase) as cohort key

Q67: D1, D7, D30 retention side by side

Approach: Three LEFT JOINs to logins on day +1, +7, +30

Q68: Unsubscribe effect on logins over 4 weeks

Approach: Cohort = unsubscribe_date, measure logins in each week_offset

Answer First: Date problems become clear after defining the interval, inclusivity, timezone, and SQL dialect.

Memory Map: Normalize dates -> choose DATEDIFF/INTERVAL/DATE_TRUNC -> state inclusive boundary -> test edge dates.

PATTERN 12: DATE ARITHMETIC

What Is It?

Calculate time intervals, filter by date ranges, create date-based groups, and compute deadlines or overdue records.

Recognize It When You See:

  • "Days between [event A] and [event B]"
  • "Orders in the past 90 days"
  • "Average time to deliver"
  • "Employees at the company more than 5 years"
  • "Renewals more than 7 days late"
  • "Group by week/month/quarter"

THE TEMPLATE

sql
-- TEMPLATE: Date Arithmetic Reference

-- 1. Difference between two dates (in days)
DATEDIFF(end_date, start_date)            -- Hive/Spark: returns integer days
end_date - start_date                      -- PostgreSQL: returns integer days
DATEDIFF(day, start_date, end_date)       -- SQL Server / Snowflake

-- 2. Add N days to a date
DATE_ADD(date_col, 7)                     -- Hive/Spark: add 7 days
date_col + INTERVAL '7 days'              -- PostgreSQL
DATEADD(day, 7, date_col)                -- SQL Server / Snowflake

-- 3. Truncate to start of period (useful for monthly/weekly grouping)
DATE_TRUNC('month', date_col)             -- PostgreSQL / Spark / Snowflake
TRUNC(date_col, 'MM')                     -- Hive / Oracle: start of month
DATE_FORMAT(date_col, 'yyyy-MM-01')       -- Hive: manual month truncation

-- 4. Extract components
EXTRACT(YEAR FROM date_col)               -- standard SQL
YEAR(date_col)                            -- Hive/Spark/MySQL shorthand
EXTRACT(QUARTER FROM date_col)            -- quarter number (1-4)

-- 5. Current date
CURRENT_DATE                              -- standard SQL
NOW()                                     -- PostgreSQL / MySQL (includes time)
GETDATE()                                 -- SQL Server
CURRENT_DATE()                            -- Hive/Spark

-- 6. Filter "past N days"
WHERE event_date >= CURRENT_DATE - INTERVAL '90 days'   -- PostgreSQL
WHERE event_date >= DATE_SUB(CURRENT_DATE(), 90)        -- Hive/Spark

-- 7. Convert epoch/unix timestamp
FROM_UNIXTIME(unix_ts)                    -- Hive: unix seconds β†’ datetime
TO_TIMESTAMP(unix_ts)                     -- Snowflake/PostgreSQL

SOLVED EXAMPLE β€” Q69 (Facebook): Days between first and last post per user

Problem:
Table: posts (user_id, post_date)
For each user who posted at least twice in 2024,
find the number of days between their first and last post of the year.
sql
SELECT
    user_id,
    MIN(post_date) AS first_post,
    MAX(post_date) AS last_post,
    DATEDIFF(MAX(post_date), MIN(post_date)) AS days_between
    -- PostgreSQL: MAX(post_date) - MIN(post_date) AS days_between
FROM posts
WHERE post_date BETWEEN '2024-01-01' AND '2024-12-31'
GROUP BY user_id
HAVING COUNT(*) >= 2   -- only users with at least 2 posts
ORDER BY days_between DESC;

SOLVED EXAMPLE β€” Q71: Employees 5+ years without promotion

Problem:
Table: employees (employee_id, hire_date)
Table: promotions (employee_id, promotion_date)
Find employees who have been at the company for 5+ years
but have NEVER been promoted.
sql
SELECT
    e.employee_id,
    e.hire_date,
    DATEDIFF(CURRENT_DATE(), e.hire_date) AS days_employed
    -- PostgreSQL: CURRENT_DATE - e.hire_date
FROM employees e
LEFT JOIN promotions p ON e.employee_id = p.employee_id
WHERE
    DATEDIFF(CURRENT_DATE(), e.hire_date) > 5 * 365   -- 5+ years
    -- PostgreSQL: CURRENT_DATE - e.hire_date > 1825
    AND p.employee_id IS NULL;   -- no record in promotions table

PATTERN 12 QUESTIONS

Q#QuestionKey Insight

Q69: Days between first and last post in year

Approach: MAX(date) - MIN(date) + HAVING COUNT >= 2

Q70: Average hours from order placement to delivery

Approach: AVG(DATEDIFF(delivered_at, placed_at) Γ— 24)

Q71: Employees 5+ years without promotion

Approach: DATEDIFF > 1825 AND LEFT JOIN promotions WHERE IS NULL

Q72: % cancelled orders and revenue lost (past 90 days)

Approach: WHERE date >= current - 90 + conditional aggregation

Q73: Subscription renewals more than 7 days late

Approach: DATEDIFF(renewal_date, expected_renewal_date) > 7

⚠️ COMMON TRAPS IN ANALYTICS PATTERNS

🧠 CTR = clicks / impressions β†’ fails if impressions = 0 for some group
CONDITIONAL AGGREGATION TRAPS
TRAP 1: Division by zero in ratios
CTRclicks / impressions β†’ fails if impressions β†’ 0 for some group
Fix: ROUND(100.0 * clicks / NULLIF(impressions, 0), 2)
NULLIF(x, 0) returns NULL if x=0 β†’ division returns NULL (not error)
TRAP 2: COUNT vs SUM for conditional counting
COUNT(CASE WHEN cond THEN 1 END) β†’ works (NULL is not counted)
SUM(CASE WHEN cond THEN 1 ELSE 0 END) β†’ works (explicit 0 for false)
SUM(CASE WHEN cond THEN 1 END) β†’ works too (NULL = 0 in SUM)
All three produce the same result for conditional counts
COHORT ANALYSIS TRAPS
TRAP 3: Using event_date not sign-up date as cohort
Cohort = first event date (MIN per user), not a separate signup table
If you use any event date as cohort: same user appears in multiple cohorts!
TRAP 4: Off-by-one in retention day calculation
Day-0 = cohort date (signup day)
Day-7 = signup_date + 7 (exactly 1 week later)
Some definitions: Day-7 = 7 days AFTER signup→signup_date + 7
Others: Day-7 = week 1β†’DATE_TRUNC('week') approach
DATE ARITHMETIC TRAPS
TRAP 5: DATEDIFF argument order (Hive vs Postgres vs SQL Server)
Hive/Spark: DATEDIFF(end_date, start_date) β†’ returns end - start (positive if end > start)
SQL Server: DATEDIFF(unit, start_date, end_date) β†’ start to end
PostgreSQL: end_date - start_date→integer subtraction
⚠️Always test: DATEDIFF('2024-01-10', '2024-01-01') should return 9
TRAP 6: Comparing timestamps vs dates
login_date = '2024-01-15'::date ← fine if column is DATE type
login_timestamp = '2024-01-15' ← WRONG if column has time component!
Fix: DATE(login_timestamp) = '2024-01-15' OR login_timestamp >= '2024-01-15 00:00:00'
Advanced

Advanced SQL Analysis

#

Advanced SQL Analysis

SQL Patterns 13–15: Recursive CTE, Median/Percentile, Funnel Analysis

The advanced patterns. Recursive CTE separates good candidates from great ones. Funnel analysis is asked at almost every product-driven data engineering role.

🧠 MEMORY MAP

sql
ADVANCED PATTERNS = "RMF"
    R β€” Recursive CTE: anchor + recursive step (trees, hierarchies, date spines)
    M β€” Median/Percentile: ROW_NUMBER trick OR PERCENTILE_CONT (built-in)
    F β€” Funnel: COUNT DISTINCT at each stage β†’ conversion rate between stages

RECURSIVE CTE STRUCTURE:
  WITH RECURSIVE cte AS (
      SELECT ...  ← ANCHOR: the starting point (root / first row)
      UNION ALL
      SELECT ... FROM cte JOIN table ...  ← RECURSIVE: add one more level
      WHERE stopping_condition   ← TERMINATION: when to stop
  )
  SELECT * FROM cte;

MEDIAN TRICK (no built-in MEDIAN):
  "The median is the middle value"
  β†’ Count total rows (N)
  β†’ Assign ROW_NUMBER to each row ordered by value
  β†’ Keep row where rn = (N+1)/2   (odd N)
  β†’ Or AVG of rows where rn IN (N/2, N/2+1)  (even N)
  β†’ PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY col) is simpler if available!

FUNNEL PATTERN:
  Define stages: signup β†’ verify β†’ first_purchase
  For each stage: COUNT(DISTINCT user_id) who reached that stage
  Conversion: next_stage_count / this_stage_count Γ— 100

Answer First: A recursive CTE combines an anchor set with a terminating recursive step to traverse a hierarchy or generate a sequence.

Memory Map: Anchor -> UNION ALL -> recursive join -> depth/cycle guard -> final projection.

PATTERN 13: RECURSIVE CTE

What Is It?

A CTE that calls itself repeatedly, adding one "level" per iteration. Used for: org chart traversal, category trees, date spine generation, dependency chains, graph reachability.

Recognize It When You See:

  • "All employees under manager X (direct AND indirect)"
  • "Full category hierarchy / subcategories recursively"
  • "Generate a sequence of dates / numbers"
  • "Task A depends on task B depends on task C β€” full chain"
  • "All cities reachable from city X via connections"

THE TEMPLATE

The recursive examples below apply to PostgreSQL and Spark 4.1+. Hive does not support recursive CTEs, and Spark versions before 4.1 require a non-recursive or iterative alternative. Spark defaults to 100 recursion levels, so longer traversals must set MAX RECURSION LEVEL. See the Spark 4.1.0 release notes, Spark 4.1 error conditions, and Hive CTE documentation.

sql
-- TEMPLATE: Recursive CTE
WITH RECURSIVE cte_name AS (

    -- ANCHOR MEMBER: base case (starting point)
    -- This runs ONCE and returns the initial rows
    SELECT
        id,
        parent_id,
        name,
        1 AS level            -- depth tracker
    FROM hierarchy_table
    WHERE parent_id IS NULL   -- ← starting condition (root nodes, or specific ID)

    UNION ALL

    -- RECURSIVE MEMBER: add one more level
    -- This references cte_name itself!
    SELECT
        h.id,
        h.parent_id,
        h.name,
        cte.level + 1
    FROM hierarchy_table h
    JOIN cte_name cte
        ON h.parent_id = cte.id   -- each iteration: find children of current level
    WHERE cte.level < 10          -- ← TERMINATION CONDITION (prevent infinite loops!)
)

SELECT * FROM cte_name
ORDER BY level, id;

-- ⚠️ ALWAYS add a termination condition (level < N OR a sentinel value)
-- Without it: infinite loop if there's a cycle in the data!

SOLVED EXAMPLE

Problem:
Table: employees (employee_id, name, manager_id)
Find ALL employees who report to manager 5 β€” direct AND indirect at all levels.
Return: employee_id, name, level (how many levels below manager 5)
sql
WITH RECURSIVE org_tree AS (

    -- ANCHOR: direct reports of manager 5
    SELECT
        employee_id,
        name,
        manager_id,
        1 AS level_below_manager
    FROM employees
    WHERE manager_id = 5

    UNION ALL

    -- RECURSIVE: find reports of the reports
    SELECT
        e.employee_id,
        e.name,
        e.manager_id,
        ot.level_below_manager + 1
    FROM employees e
    JOIN org_tree ot ON e.manager_id = ot.employee_id
    WHERE ot.level_below_manager < 20   -- safety limit (company depth)
)

SELECT employee_id, name, level_below_manager
FROM org_tree
ORDER BY level_below_manager, name;

SOLVED EXAMPLE β€” Q78: Generate date spine 2024-01-01 to 2024-12-31

Problem:
Generate a table of all dates in 2024.
Use for LEFT JOIN to fill in missing dates in reporting (zero-fill).
sql
-- PostgreSQL: use generate_series (simplest)
SELECT generate_series(
    '2024-01-01'::date,
    '2024-12-31'::date,
    '1 day'::interval
)::date AS date_val;

-- Spark 4.1+: recursive CTE support starts in this release line
WITH RECURSIVE date_spine(date_val) MAX RECURSION LEVEL 366 AS (
    -- ANCHOR: start date
    SELECT DATE '2024-01-01' AS date_val

    UNION ALL

    -- RECURSIVE: add 1 day each iteration
    SELECT DATE_ADD(date_val, 1)
    FROM date_spine
    WHERE date_val < DATE '2024-12-31'   -- TERMINATION: stop at end date
)

SELECT date_val FROM date_spine;

-- Spark 3.x/4.0 alternative for a date spine: no recursion required
SELECT EXPLODE(SEQUENCE(
    TO_DATE('2024-01-01'),
    TO_DATE('2024-12-31'),
    INTERVAL 1 DAY
)) AS date_val;

-- Hive alternative: select from a maintained calendar dimension
SELECT calendar_date AS date_val
FROM dim_calendar
WHERE calendar_date BETWEEN DATE '2024-01-01' AND DATE '2024-12-31';

-- Usage: fill gaps in daily sales report
SELECT
    d.date_val,
    p.product_id,
    COALESCE(s.revenue, 0) AS revenue
FROM date_spine d
CROSS JOIN (SELECT DISTINCT product_id FROM products) p
LEFT JOIN daily_sales s
    ON d.date_val = s.sale_date
    AND p.product_id = s.product_id
ORDER BY d.date_val, p.product_id;

PATTERN 13 QUESTIONS

Q#QuestionKey Insight

Q74: All employees under manager_id=5 (all levels)

Approach: Anchor = direct reports, recursive = reports of reports

Q75: Total subordinates (direct + indirect) per manager

Approach: Recursive + COUNT(*) in outer query

Q76: Product categories + all subcategories recursively

Approach: Anchor = top-level categories (parent_id IS NULL)

Q77: Full task dependency chain

Approach: Anchor = the target task, recursive = its dependencies

Q78: Generate date spine 2024-01-01 to 2024-12-31

Approach: Anchor = start date, recursive = date + 1 day

Q79: All cities reachable from origin (multi-hop flights)

Approach: Anchor = direct destinations, recursive = reachable from those

Answer First: Use a percentile function when available; otherwise rank values and average the middle one or two positions.

Memory Map: Sort -> count -> locate middle rank(s) -> average; use NTILE for buckets, not an exact percentile.

PATTERN 14: MEDIAN / PERCENTILE

What Is It?

Find the middle value of a distribution (median) or a specific percentile (90th, 95th). Built-in functions vary by database β€” know both the function AND the manual approach.

Recognize It When You See:

  • "Median [salary/value/time]"
  • "Middle value"
  • "Percentile / quartile / decile"
  • "P50, P90, P95, P99"
  • "Divide users into quartiles / buckets"

APPROACH 1: Built-in Functions (when available)

sql
-- PERCENTILE_CONT: continuous interpolation (for medians β€” interpolates between values)
SELECT
    PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY salary) AS median_salary,
    PERCENTILE_CONT(0.9) WITHIN GROUP (ORDER BY salary) AS p90_salary,
    PERCENTILE_CONT(0.25) WITHIN GROUP (ORDER BY salary) AS q1_salary
FROM employees;

-- Availability: PostgreSQL βœ“, Snowflake βœ“, Spark SQL βœ“, Hive (PERCENTILE_APPROX) partial

-- PERCENTILE_DISC: discrete (returns actual value from the dataset, not interpolated)
SELECT
    PERCENTILE_DISC(0.5) WITHIN GROUP (ORDER BY salary) AS exact_median
FROM employees;

-- NTILE: divide into N equal buckets
SELECT
    user_id,
    total_spend,
    NTILE(4) OVER (ORDER BY total_spend) AS quartile   -- 1=lowest, 4=highest
FROM user_spending;

-- Hive approximation:
SELECT PERCENTILE_APPROX(salary, 0.5) AS approx_median FROM employees;

APPROACH 2: Manual Median (when no built-in β€” important interview skill!)

sql
-- MANUAL MEDIAN using ROW_NUMBER
-- Works in ALL databases including Hive

WITH ordered AS (
    SELECT
        value_col,
        ROW_NUMBER() OVER (ORDER BY value_col) AS rn,
        COUNT(*) OVER () AS total_count   -- total rows (same for all rows)
    FROM source_table
)

SELECT AVG(value_col) AS median_value
FROM ordered
WHERE
    rn = (total_count + 1) / 2             -- middle row for odd count
    OR rn = (total_count + 2) / 2;         -- handles even count (avg of 2 middle rows)

-- Example:
-- N=5 (odd): middle = row 3. (5+1)/2=3, (5+2)/2=3 β†’ same row β†’ just 1 value
-- N=6 (even): rows 3 and 4. (6+1)/2=3 (floor), (6+2)/2=4 β†’ two rows β†’ AVG

SOLVED EXAMPLE β€” Q80 (Google Hard): Median from a FREQUENCY TABLE

Problem:
Table: search_frequency (searches, num_users)
Each row says: X users made exactly Y searches.
(This is a COMPRESSED representation β€” not individual rows!)
Find the median number of searches per user.
Example:
searches | num_users
1 | 2 ← 2 users made 1 search each
2 | 3 ← 3 users made 2 searches each
3 | 1 ← 1 user made 3 searches
Total: 6 users. Median = (2+2)/2 = 2 (3rd and 4th values in sorted list)
sql
-- KEY INSIGHT: Can't use simple ROW_NUMBER because data is compressed!
-- Must EXPAND the frequency table first OR use cumulative counts

WITH cumulative AS (
    SELECT
        searches,
        num_users,
        -- Cumulative count up to this row
        SUM(num_users) OVER (ORDER BY searches
                              ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)
            AS cum_count,
        -- Cumulative count BEFORE this row
        SUM(num_users) OVER (ORDER BY searches
                              ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING)
            AS cum_count_before,
        SUM(num_users) OVER () AS total_users
    FROM search_frequency
)

SELECT ROUND(AVG(searches * 1.0), 1) AS median_searches
FROM cumulative
WHERE
    -- Median rows: total/2 falls within [cum_before, cum_count] range
    COALESCE(cum_count_before, 0) < (total_users + 1) / 2.0
    AND cum_count >= (total_users + 1) / 2.0;
    -- For even totals: both (N/2)th and (N/2+1)th values needed β†’ AVG handles it

SOLVED EXAMPLE β€” Q83: Divide users into quartiles by total spend

Problem:
Table: user_orders (user_id, order_date, amount)
Divide users into 4 quartiles based on their total spend in 2023.
Return: user_id, total_spend, quartile (1=lowest, 4=highest)
sql
WITH user_totals AS (
    SELECT
        user_id,
        SUM(amount) AS total_spend
    FROM user_orders
    WHERE YEAR(order_date) = 2023
    GROUP BY user_id
)

SELECT
    user_id,
    total_spend,
    NTILE(4) OVER (ORDER BY total_spend ASC) AS quartile
    -- quartile 1 = lowest spenders, quartile 4 = highest spenders
FROM user_totals
ORDER BY total_spend;

PATTERN 14 QUESTIONS

Q#QuestionKey Insight

Q80: Median searches from frequency table (Google Hard)

Approach: Expand compressed freq table using cumulative SUM

Q81: Median salary per department

Approach: ROW_NUMBER per dept + filter middle row(s)

Q82: 90th percentile response time per API endpoint

Approach: PERCENTILE_CONT(0.90) or ROW_NUMBER approach

Q83: Divide users into 4 quartiles by spend

Approach: NTILE(4) OVER (ORDER BY total_spend)

Q84: Median days between 1st and 2nd purchase

Approach: Compute day_diff per user, then apply median pattern

Answer First: A funnel marks whether each user reached each ordered stage, then aggregates stage counts and conversion rates.

Memory Map: Scope window -> one row per user -> stage flags/timestamps -> stage counts -> step conversion and drop-off.

PATTERN 15: FUNNEL ANALYSIS

What Is It?

Track how many users pass through each stage of a defined sequence. Calculate conversion rates between stages and identify where users drop off.

Recognize It When You See:

  • "Conversion funnel / conversion rate"
  • "How many users reached step X"
  • "Drop-off rate at each stage"
  • "Signup β†’ verification β†’ first purchase"
  • "Impression β†’ click β†’ purchase"

THE TEMPLATE

sql
-- TEMPLATE: Funnel Analysis
-- Each user can be in at most one stage at a time (or multiple, depending on definition)

WITH user_stages AS (
    SELECT
        user_id,
        -- For each stage: 1 if user performed this action, else 0
        MAX(CASE WHEN event_type = 'signup' THEN 1 ELSE 0 END)        AS did_signup,
        MAX(CASE WHEN event_type = 'email_verified' THEN 1 ELSE 0 END) AS did_verify,
        MAX(CASE WHEN event_type = 'first_purchase' THEN 1 ELSE 0 END) AS did_purchase
    FROM events
    GROUP BY user_id
),

funnel_counts AS (
    SELECT
        -- COUNT users who reached each stage
        COUNT(*) AS total_users,
        SUM(did_signup) AS signup_count,
        SUM(did_verify) AS verify_count,
        SUM(did_purchase) AS purchase_count
    FROM user_stages
)

SELECT
    signup_count,
    verify_count,
    ROUND(100.0 * verify_count / NULLIF(signup_count, 0), 2)   AS signup_to_verify_pct,
    purchase_count,
    ROUND(100.0 * purchase_count / NULLIF(verify_count, 0), 2) AS verify_to_purchase_pct,
    ROUND(100.0 * purchase_count / NULLIF(signup_count, 0), 2) AS overall_conversion_pct
FROM funnel_counts;

SOLVED EXAMPLE β€” Q85 (TikTok/Stripe): Count users at each funnel stage + conversion rates

Problem:
Table: events (user_id, event_type, event_date)
event_type values: 'signup', 'email_verified', 'profile_completed', 'first_purchase'
For each stage: count distinct users who reached it.
Also compute step-to-step conversion rate.
sql
WITH user_funnel AS (
    SELECT
        user_id,
        MAX(CASE WHEN event_type = 'signup' THEN 1 ELSE 0 END)             AS reached_signup,
        MAX(CASE WHEN event_type = 'email_verified' THEN 1 ELSE 0 END)      AS reached_verify,
        MAX(CASE WHEN event_type = 'profile_completed' THEN 1 ELSE 0 END)   AS reached_profile,
        MAX(CASE WHEN event_type = 'first_purchase' THEN 1 ELSE 0 END)      AS reached_purchase
    FROM events
    GROUP BY user_id
),

stage_counts AS (
    SELECT
        SUM(reached_signup)   AS signup_users,
        SUM(reached_verify)   AS verify_users,
        SUM(reached_profile)  AS profile_users,
        SUM(reached_purchase) AS purchase_users
    FROM user_funnel
)

SELECT
    'Stage 1: Signup'        AS stage, signup_users   AS users, 100.0 AS conversion_from_prev FROM stage_counts
UNION ALL
SELECT 'Stage 2: Email Verified', verify_users,
    ROUND(100.0 * verify_users   / NULLIF(signup_users, 0), 2)   FROM stage_counts
UNION ALL
SELECT 'Stage 3: Profile Completed', profile_users,
    ROUND(100.0 * profile_users  / NULLIF(verify_users, 0), 2)   FROM stage_counts
UNION ALL
SELECT 'Stage 4: First Purchase', purchase_users,
    ROUND(100.0 * purchase_users / NULLIF(profile_users, 0), 2)  FROM stage_counts;

SOLVED EXAMPLE β€” Q86 (TikTok): Signup-to-activation rate

Problem:
Table: emails (email_id, user_id, action, date)
action values: 'sent' (signup triggered), 'open' (user opened), 'answered' (activated)
Find activation rate = users who 'answered' / total users (2 decimal places)
sql
SELECT
    ROUND(
        SUM(CASE WHEN action = 'answered' THEN 1.0 ELSE 0 END)
        / COUNT(DISTINCT user_id),
    2) AS activation_rate
FROM emails;

-- More explicit version:
WITH user_status AS (
    SELECT
        user_id,
        MAX(CASE WHEN action = 'answered' THEN 1 ELSE 0 END) AS activated
    FROM emails
    GROUP BY user_id
)
SELECT ROUND(AVG(activated * 1.0), 2) AS activation_rate FROM user_status;

PATTERN 15 QUESTIONS

Q#QuestionKey Insight

Q85: Count users at each funnel stage + conversion rates

Approach: MAX(CASE WHEN event=stage) per user, then aggregate

Q86: Signup-to-activation rate

Approach: COUNT(answered) / COUNT(signed_up)

Q87: Drop-off rate at each onboarding step

Approach: Funnel template + find biggest % drop between consecutive stages

Q88: CTR-to-conversion per ad campaign

Approach: Impressions β†’ clicks β†’ purchases (3-stage funnel)

Q89: Funnel step with highest drop-off (first 7 days)

Approach: Compute all stage counts, then find MAX(step_n - step_n+1)

Q90: Free trial β†’ paid conversion across monthly cohorts

Approach: Cohort = signup_month + funnel (trial β†’ paid) per cohort

⚠️ COMMON TRAPS IN ADVANCED PATTERNS

RECURSIVE CTE TRAPS
TRAP 1: Termination and limits are engine-specific
PostgreSQL has no equivalent fixed recursion-depth setting; termination is query/data-driven
Spark 4.1 defaults to 100 recursion levels (`spark.sql.cteRecursionLevelLimit`)
In Spark, override the limit with MAX RECURSION LEVEL when a valid traversal needs more steps
TRAP 2: UNION ALL vs UNION differs by engine
PostgreSQL permits `UNION` and `UNION ALL` in a recursive CTE
`UNION` can terminate recursion when it removes repeated output rows; use CYCLE/path checks for other cycles
Spark 4.1 recursive CTEs require `UNION ALL`; plain UNION is rejected
TRAP 3: Recursive support is dialect- and version-specific
Hive does not support recursive CTEs: use iterative jobs or a prebuilt calendar table / hierarchy table
Spark supports WITH RECURSIVE only in Spark 4.1+; older Spark needs iteration/GraphFrames
For an older-Spark date spine, EXPLODE(SEQUENCE(...)) avoids recursion entirely
MEDIAN TRAPS
TRAP 4: Simple AVG β‰  Median
AVG(salary) = arithmetic average (pulled by outliers)
MEDIAN(salary) = 50th percentile (middle value, resistant to outliers)
Interviewers ask for MEDIAN specifically to test this distinction
TRAP 5: Even vs Odd count
Odd count (N=5): median = row 3 β†’ one row
Even count (N=6): median = average of rows 3 and 4 β†’ TWO rows, then AVG
ROW_NUMBER trick: WHERE rn IN ((N+1)/2, (N+2)/2) handles BOTH cases with AVG
FUNNEL TRAPS
TRAP 6: User can appear in multiple stages
A user who completed 'first_purchase' ALSO did 'signup' and 'email_verified'
Use MAX(CASE WHEN event=stage THEN 1 ELSE 0 END) per user
NOT COUNT(CASE WHEN event=stage) β€” that counts events, not unique users!
TRAP 7: Strict funnel vs non-strict
Strict: user must complete stages IN ORDER (signup before purchase)
Non-strict: just check if user ever performed each action
Most interview questions are non-strict unless explicitly stated
For strict funnel: add timestamp comparison in JOIN conditions

Dialect references: PostgreSQL recursive queries, Spark 4.1 recursive-CTE errors, and Spark 4.1 configuration.

Intermediate

SQL Scenarios, Labs, and Confusions

#

SQL Scenarios, Labs, and Confusions

SQL β€” Confusions, Labs, Gotchas & Mock Interview

πŸ’‘ Interview Tip
Goal: After this page, you should NEVER struggle with SQL concepts or interview questions again. Approach: Clear up top confusions β†’ run labs β†’ see animations β†’ know gotchas β†’ practice mock interview. Where to run labs: db-fiddle.com (PostgreSQL), DuckDB, or SQLite.

Memory Map

🧠 SQL MASTERY β†’ CLEAR-JOIN-WINDOW
SQL MASTERYCLEAR-JOIN-WINDOW
──────────────────────────────
CConfusions (HAVING vs WHERE, JOIN types, UNION, RANK variants)
LLabs (run queries, see intermediate results)
EErrors / Gotchas (the ones that break pipelines)
AAnimations (visualize JOINs, window frames, GROUP BY)
RReadiness (mock interview + final checklist)

SECTION 0: TOP 8 SQL CONFUSIONS β€” Cleared Forever

πŸ’‘ Interview Tip
Interviewers LOVE these. Get them right and you sound senior.

Answer First: WHERE filters rows before grouping; HAVING filters groups after aggregation.

Memory Map: FROM/JOIN -> WHERE -> GROUP BY -> HAVING -> SELECT -> ORDER BY.

Confusion 1: HAVING vs WHERE

The Simple Answer:

WHEREfilters ROWS (before grouping)
HAVINGfilters GROUPS (after aggregation)

Why it matters: You CANNOT use aggregates in WHERE. You CANNOT filter non-aggregated columns in HAVING (well, you can, but WHERE is faster).

Visual:

πŸ“ Architecture Diagram
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  Raw Table (orders)                                β”‚
β”‚  ─────────────                                     β”‚
β”‚  cust_id β”‚ amount β”‚ status                         β”‚
β”‚  ──────────────────────────                        β”‚
β”‚     1    β”‚  100   β”‚ paid                           β”‚
β”‚     1    β”‚  200   β”‚ paid                           β”‚
β”‚     2    β”‚   50   β”‚ cancelled  ← filter out (WHERE) β”‚
β”‚     2    β”‚  300   β”‚ paid                           β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
         β”‚
         β”‚ 1. WHERE status = 'paid'   ← removes row-by-row
         β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  cust_id β”‚ amount β”‚ status                         β”‚
β”‚     1    β”‚  100   β”‚ paid                           β”‚
β”‚     1    β”‚  200   β”‚ paid                           β”‚
β”‚     2    β”‚  300   β”‚ paid                           β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
         β”‚
         β”‚ 2. GROUP BY cust_id       ← aggregates
         β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  cust_id β”‚ SUM(amount)                             β”‚
β”‚     1    β”‚  300                                    β”‚
β”‚     2    β”‚  300                                    β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
         β”‚
         β”‚ 3. HAVING SUM(amount) > 200  ← filters AGGREGATED result
         β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  cust_id β”‚ SUM(amount)                             β”‚
β”‚     1    β”‚  300                                    β”‚
β”‚     2    β”‚  300                                    β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Interview answer:

"WHERE filters individual rows BEFORE grouping. HAVING filters groups AFTER aggregation. You can only use aggregate functions like SUM, COUNT in HAVING β€” not WHERE."

Gotcha: If the filter doesn't need an aggregate, use WHERE β€” it's faster because fewer rows get grouped.

Answer First: Join type decides which unmatched rows survive; the ON predicate decides which rows match.

Memory Map: Pick preserved side(s) -> define ON keys -> inspect duplicates -> place post-join filters deliberately.

Confusion 2: INNER vs LEFT vs RIGHT vs FULL OUTER JOIN

Visual (2 tables):

πŸ“ Architecture Diagram
Table A (Customers):      Table B (Orders):
β”Œβ”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”           β”Œβ”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ id β”‚ name  β”‚           β”‚ id β”‚ order_id β”‚
β”œβ”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€           β”œβ”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ 1  β”‚ Alice β”‚           β”‚ 1  β”‚ 101      β”‚
β”‚ 2  β”‚ Bob   β”‚           β”‚ 1  β”‚ 102      β”‚
β”‚ 3  β”‚ Carol β”‚           β”‚ 4  β”‚ 103      β”‚  ← id=4 has no customer
β””β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”˜           β””β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

JOIN TYPE         WHAT YOU GET
─────────────     ─────────────────────────────────────────
INNER JOIN    β†’   Only matches in BOTH tables
                  (Alice-101, Alice-102)
                  [id=2 Bob, id=3 Carol dropped β€” no orders]
                  [id=4 order dropped β€” no customer]

LEFT JOIN     β†’   ALL from A + matching from B (NULL if no match)
                  (Alice-101, Alice-102, Bob-NULL, Carol-NULL)

RIGHT JOIN    β†’   ALL from B + matching from A (NULL if no match)
                  (Alice-101, Alice-102, NULL-103)

FULL OUTER    β†’   ALL from A + ALL from B (NULL where no match)
                  (Alice-101, Alice-102, Bob-NULL, Carol-NULL, NULL-103)

CROSS JOIN    β†’   Every row Γ— every row (Cartesian)
                  3 Γ— 3 = 9 rows (no join condition)

Memory trick:

🧠 LEFT = Keep everything on the LEFT table (A)
LEFTKeep everything on the LEFT table (A)
RIGHTKeep everything on the RIGHT table (B)
INNERKeep only INTERSECTION
FULLKeep UNION (everything from both)

Interview trap: "When would you use RIGHT JOIN over LEFT JOIN?" Good answer: "Almost never β€” LEFT JOIN is more readable because you list the 'primary' table first. RIGHT JOIN just flips that. I'd use RIGHT only if the query flow reads more naturally that way, e.g., when joining onto a fact table that's already referenced."

Answer First: UNION removes duplicates; UNION ALL preserves them and avoids the distinct step.

Memory Map: Compatible columns -> decide set or bag semantics -> use UNION ALL unless deduplication is required.

Confusion 3: UNION vs UNION ALL

UNIONcombines results + REMOVES duplicates (slower)
UNION ALLcombines results + KEEPS duplicates (faster)

Example:

sql
SELECT cust_id FROM orders_2025
UNION       -- removes dupes
SELECT cust_id FROM orders_2026;
-- Result: unique cust_ids who ordered in EITHER year

SELECT cust_id FROM orders_2025
UNION ALL   -- keeps dupes
SELECT cust_id FROM orders_2026;
-- Result: all cust_ids (a customer in both years appears twice)

Interview trap: "Which is faster?" β†’ UNION ALL (no deduplication step). Rule: Default to UNION ALL unless you SPECIFICALLY need dedup.

Answer First: ROW_NUMBER is unique, RANK leaves gaps after ties, and DENSE_RANK does not.

Memory Map: Need one row -> ROW_NUMBER; competition gaps -> RANK; top-N values with ties -> DENSE_RANK.

Confusion 4: RANK vs DENSE_RANK vs ROW_NUMBER

The #1 window function confusion. Memorize this:

🧠 Memory Map
Data: salaries = [100, 90, 90, 80, 70]
ROW_NUMBER RANK DENSE_RANK
salary=100β†’1 1 1
salary=90β†’2 2 2 ← tied
salary=90β†’3 2 2 ← tied (same rank)
salary=80β†’4 4 3 ← RANK skips 3, DENSE_RANK doesn't
salary=70β†’5 5 4

Memory trick:

🧠 RANK β†’ ties get same rank, SKIPS next (1, 2, 2, 4, 5)
ROW_NUMBER→always sequential (1, 2, 3, 4, 5) — no ties allowed
RANKties get same rank, SKIPS next (1, 2, 2, 4, 5)
DENSE_RANK→ties get same rank, NO SKIP (1, 2, 2, 3, 4)

When to use which:

  • ROW_NUMBER: pick ONE row per group (e.g., latest order per customer)
  • RANK: Olympic-style ranking (2 gold β†’ no silver)
  • DENSE_RANK: when you want consecutive ranks even with ties (top 3 distinct scores)

Interview question: "Find the 3rd highest salary."

sql
-- Using DENSE_RANK (treats ties as same rank, returns ONE "3rd highest")
SELECT DISTINCT salary FROM (
    SELECT salary, DENSE_RANK() OVER (ORDER BY salary DESC) AS rk
    FROM employees
) t WHERE rk = 3;

Answer First: GROUP BY collapses rows; a window calculation keeps row detail while adding a group-relative value.

Memory Map: Desired output grain -> collapse with GROUP BY or retain with OVER -> choose partition and frame.

Confusion 5: GROUP BY aggregation vs Window functions

GROUP BYCOLLAPSES rows (1 row per group)
Window function→KEEPS all rows (adds aggregate as new column)

Same data, different result:

sql
-- GROUP BY: collapses
SELECT dept, AVG(salary)
FROM employees
GROUP BY dept;
-- Output: 1 row per dept
-- sales | 55000
-- eng   | 75000

-- Window: keeps all rows
SELECT name, dept, salary, AVG(salary) OVER (PARTITION BY dept) AS dept_avg
FROM employees;
-- Output: every employee + their dept avg
-- Alice | sales | 50000 | 55000
-- Bob   | sales | 60000 | 55000
-- Carol | eng   | 80000 | 75000
-- Dave  | eng   | 70000 | 75000

Rule:

  • Need to compare each row to its group avg? β†’ Window function
  • Just need totals per group? β†’ GROUP BY

Answer First: DELETE removes selected rows, TRUNCATE clears a table efficiently, and DROP removes the object.

Memory Map: Need predicate/rollback/triggers? DELETE. Keep structure? TRUNCATE. Remove structure? DROP.

Confusion 6: DELETE vs TRUNCATE vs DROP

sql
DELETE    β†’ removes ROWS (can use WHERE, logged, slow, rollback-able)
TRUNCATE  β†’ removes ALL rows (no WHERE, minimal logging, fast, usually not rollback-able)
DROP      β†’ removes ENTIRE TABLE (schema + data + indexes)

Speed ranking: DROP > TRUNCATE > DELETE (slowest)

Interview trap: "Can TRUNCATE be rolled back?"

  • PostgreSQL, SQL Server: Yes, inside a transaction
  • MySQL, Oracle: No (auto-commit, DDL)
  • Safe answer: "It depends on the database β€” in most RDBMSes it's DDL and auto-commits, so no rollback."

Answer First: A correlated subquery depends on the outer row; a non-correlated subquery can run independently.

Memory Map: Find outer references -> estimate repeated work -> prefer set-based join/window when it clarifies intent.

Confusion 7: Correlated vs Non-Correlated Subquery

Non-correlated: inner query runs ONCE (independent)
Correlated: inner query runs PER ROW of outer query (depends on outer)

Non-correlated (fast):

sql
SELECT name, salary
FROM employees
WHERE salary > (SELECT AVG(salary) FROM employees);
--                      ↑ runs once, returns single value

Correlated (slow, avoid when possible):

sql
SELECT name, salary
FROM employees e1
WHERE salary > (
    SELECT AVG(salary)
    FROM employees e2
    WHERE e2.dept = e1.dept   -- ← references OUTER query e1
                              -- runs once for EACH row of e1
);

Interview trap: "Correlated subqueries are always bad." β†’ FALSE. Sometimes they're the clearest way to express per-group logic. Modern optimizers can rewrite many correlated subqueries as joins automatically.

Answer First: Choose the construct that makes grain and reuse explicit: a subquery for a local step, CTE for named stages, window for row-relative results.

Memory Map: Output grain -> dependency stages -> reuse/materialization needs -> simplest readable construct.

Confusion 8: When to use Subquery vs CTE vs Window Function

🧠 Memory Map
Subquery→inline, one-use, unnamed
CTE (WITH) β†’ named, reusable in query, self-documenting, can be recursive
Window fn→aggregation WITHOUT collapsing rows

Same problem, 3 ways β€” "Find employees earning more than their dept avg":

sql
-- Subquery (messy for complex cases)
SELECT name FROM employees e
WHERE salary > (SELECT AVG(salary) FROM employees WHERE dept = e.dept);

-- CTE (clearer)
WITH dept_avg AS (
    SELECT dept, AVG(salary) AS avg_sal FROM employees GROUP BY dept
)
SELECT e.name FROM employees e
JOIN dept_avg d ON e.dept = d.dept
WHERE e.salary > d.avg_sal;

-- Window function (cleanest β€” single pass)
SELECT name FROM (
    SELECT name, salary, AVG(salary) OVER (PARTITION BY dept) AS avg_sal
    FROM employees
) t WHERE salary > avg_sal;

Interview answer: "I reach for window functions first when I need per-row comparisons to group aggregates. I use CTEs when the logic has multiple steps or needs to be reused. I use inline subqueries only for trivial single-value lookups."

APPENDIX: LEARN BY DOING

(This section replaces videos β€” copy-paste + see output)

Answer First: This lab demonstrates the execution-order difference between row filtering and group filtering.

Memory Map: Load sample -> predict WHERE result -> predict HAVING result -> execute -> explain the difference.

LAB 1 β€” Watch WHERE vs HAVING Execute Step-by-Step

Where to run: db-fiddle.com (PostgreSQL), or paste into any SQL tool. Time: 5 minutes

sql
-- SETUP
CREATE TABLE orders (
    cust_id INT,
    amount  DECIMAL,
    status  VARCHAR(20)
);

INSERT INTO orders VALUES
    (1, 100, 'paid'),
    (1, 200, 'paid'),
    (2,  50, 'cancelled'),
    (2, 300, 'paid'),
    (3, 150, 'paid'),
    (3,  80, 'cancelled');

-- STEP 1: just look at raw data
SELECT * FROM orders;
-- β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
-- β”‚ cust_id β”‚ amount β”‚  status   β”‚
-- β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
-- β”‚    1    β”‚  100   β”‚  paid     β”‚
-- β”‚    1    β”‚  200   β”‚  paid     β”‚
-- β”‚    2    β”‚   50   β”‚  cancelledβ”‚
-- β”‚    2    β”‚  300   β”‚  paid     β”‚
-- β”‚    3    β”‚  150   β”‚  paid     β”‚
-- β”‚    3    β”‚   80   β”‚  cancelledβ”‚
-- β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
sql
-- STEP 2: WHERE filters rows BEFORE GROUP BY
SELECT cust_id, SUM(amount) AS total
FROM orders
WHERE status = 'paid'        -- ← filters row-by-row FIRST
GROUP BY cust_id;
-- β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”
-- β”‚ cust_id β”‚ total β”‚
-- β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€
-- β”‚    1    β”‚  300  β”‚    ← 100+200 (only paid rows)
-- β”‚    2    β”‚  300  β”‚    ← 300 only (50 cancelled filtered out by WHERE)
-- β”‚    3    β”‚  150  β”‚    ← 150 only (80 cancelled filtered out)
-- β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”˜
sql
-- STEP 3: HAVING filters GROUPS AFTER GROUP BY
SELECT cust_id, SUM(amount) AS total
FROM orders
WHERE status = 'paid'
GROUP BY cust_id
HAVING SUM(amount) > 200;    -- ← filters aggregated result AFTER
-- β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”
-- β”‚ cust_id β”‚ total β”‚
-- β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€
-- β”‚    1    β”‚  300  β”‚    ← kept (300 > 200)
-- β”‚    2    β”‚  300  β”‚    ← kept (300 > 200)
-- β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”˜       ← cust_id=3 dropped (150 not > 200)
sql
-- STEP 4: What breaks if you try to use aggregate in WHERE?
SELECT cust_id, SUM(amount)
FROM orders
WHERE SUM(amount) > 200       -- ← ❌ ERROR
GROUP BY cust_id;

-- ERROR: aggregate functions are not allowed in WHERE
-- Fix: use HAVING instead

🎯 Key takeaway: Execution order is FROM β†’ WHERE β†’ GROUP BY β†’ HAVING β†’ SELECT β†’ ORDER BY.

Answer First: This lab makes matched and unmatched join rows visible across join types.

Memory Map: Build tiny tables -> mark keys -> predict pairs -> execute each join -> count unmatched rows.

LAB 2 β€” Watch JOINs Actually Join

sql
-- SETUP
DROP TABLE IF EXISTS customers;
DROP TABLE IF EXISTS orders;

CREATE TABLE customers (id INT, name VARCHAR(20));
CREATE TABLE orders    (id INT, order_num INT);

INSERT INTO customers VALUES (1,'Alice'),(2,'Bob'),(3,'Carol');
INSERT INTO orders    VALUES (1, 101), (1, 102), (4, 103);

-- Look at both tables
SELECT * FROM customers;
-- id | name
-- 1  | Alice
-- 2  | Bob
-- 3  | Carol
SELECT * FROM orders;
-- id | order_num
-- 1  | 101
-- 1  | 102
-- 4  | 103          ← id=4 is NOT in customers!
sql
-- STEP 1: INNER JOIN β€” only matches
SELECT c.name, o.order_num
FROM customers c
INNER JOIN orders o ON c.id = o.id;
-- Alice | 101
-- Alice | 102
-- ← Bob, Carol missing (no orders)
-- ← order 103 missing (no matching customer)
sql
-- STEP 2: LEFT JOIN β€” all customers, orders if present
SELECT c.name, o.order_num
FROM customers c
LEFT JOIN orders o ON c.id = o.id;
-- Alice | 101
-- Alice | 102
-- Bob   | NULL     ← Bob kept, no order
-- Carol | NULL     ← Carol kept, no order
-- ← order 103 still missing (it's RIGHT-side)
sql
-- STEP 3: FULL OUTER JOIN β€” everything
SELECT c.name, o.order_num
FROM customers c
FULL OUTER JOIN orders o ON c.id = o.id;
-- Alice | 101
-- Alice | 102
-- Bob   | NULL
-- Carol | NULL
-- NULL  | 103      ← the orphan order appears!
sql
-- STEP 4: "Find customers with NO orders" (anti-join pattern)
SELECT c.name
FROM customers c
LEFT JOIN orders o ON c.id = o.id
WHERE o.id IS NULL;          -- ← the magic: WHERE rhs IS NULL
-- Bob
-- Carol

🎯 Interview gold: The "LEFT JOIN + WHERE IS NULL" is the standard pattern for "find X without Y." Memorize it.

Answer First: This lab shows that window functions calculate across related rows without collapsing them.

Memory Map: Order input -> choose partition -> inspect frame -> compute each row -> compare rank functions.

LAB 3 β€” Watch Window Functions Compute

sql
-- SETUP
DROP TABLE IF EXISTS salaries;
CREATE TABLE salaries (name VARCHAR(20), dept VARCHAR(20), salary INT);
INSERT INTO salaries VALUES
    ('Alice',  'eng',   90000),
    ('Bob',    'eng',   75000),
    ('Carol',  'eng',   75000),
    ('Dave',   'eng',   60000),
    ('Eve',    'sales', 80000),
    ('Frank',  'sales', 70000);
sql
-- STEP 1: See all 3 ranking functions side-by-side
SELECT name, dept, salary,
       ROW_NUMBER() OVER (PARTITION BY dept ORDER BY salary DESC) AS rn,
       RANK()       OVER (PARTITION BY dept ORDER BY salary DESC) AS rk,
       DENSE_RANK() OVER (PARTITION BY dept ORDER BY salary DESC) AS drk
FROM salaries;
-- β”Œβ”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”
-- β”‚ name  β”‚ dept  β”‚ salary β”‚ rn β”‚ rk β”‚ drk β”‚
-- β”œβ”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€
-- β”‚ Alice β”‚ eng   β”‚ 90000  β”‚ 1  β”‚ 1  β”‚  1  β”‚
-- β”‚ Bob   β”‚ eng   β”‚ 75000  β”‚ 2  β”‚ 2  β”‚  2  β”‚  ← tied
-- β”‚ Carol β”‚ eng   β”‚ 75000  β”‚ 3  β”‚ 2  β”‚  2  β”‚  ← tied (RANK same, DENSE same)
-- β”‚ Dave  β”‚ eng   β”‚ 60000  β”‚ 4  β”‚ 4  β”‚  3  β”‚  ← RANK skips to 4, DENSE β†’ 3
-- β”‚ Eve   β”‚ sales β”‚ 80000  β”‚ 1  β”‚ 1  β”‚  1  β”‚
-- β”‚ Frank β”‚ sales β”‚ 70000  β”‚ 2  β”‚ 2  β”‚  2  β”‚
-- β””β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”˜
sql
-- STEP 2: Running totals with SUM() OVER
SELECT name, dept, salary,
       SUM(salary) OVER (PARTITION BY dept ORDER BY salary DESC) AS running_total
FROM salaries;
-- Alice β”‚ eng   β”‚ 90000 β”‚  90000       ← 90k
-- Bob   β”‚ eng   β”‚ 75000 β”‚ 165000       ← 90+75 = 165k  (same for Carol, tied)
-- Carol β”‚ eng   β”‚ 75000 β”‚ 240000       ← wait, why? See note below
-- Dave  β”‚ eng   β”‚ 60000 β”‚ 300000
--
-- πŸ’‘ Why Bob+Carol give DIFFERENT running totals despite tied salary?
--    Default frame is RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW.
--    With ORDER BY salary DESC, ties are "one range" β€” both get the
--    same running total in SOME databases, different in others.
--    Safe fix: use ROWS instead of RANGE.
sql
-- STEP 3: LAG / LEAD β€” compare rows to neighbors
SELECT name, salary,
       LAG(salary)  OVER (ORDER BY salary DESC) AS prev_sal,
       LEAD(salary) OVER (ORDER BY salary DESC) AS next_sal,
       salary - LAG(salary) OVER (ORDER BY salary DESC) AS diff_from_prev
FROM salaries;
-- Alice β”‚ 90000 β”‚  NULL β”‚ 80000 β”‚  NULL    ← no previous row
-- Eve   β”‚ 80000 β”‚ 90000 β”‚ 75000 β”‚ -10000   ← 10k less than prev
-- Bob   β”‚ 75000 β”‚ 80000 β”‚ 75000 β”‚  -5000
-- Carol β”‚ 75000 β”‚ 75000 β”‚ 70000 β”‚      0
-- Frank β”‚ 70000 β”‚ 75000 β”‚ 60000 β”‚  -5000
-- Dave  β”‚ 60000 β”‚ 70000 β”‚  NULL β”‚ -10000

🎯 LAG(col, 1) = value in previous row. LEAD(col, 1) = value in next row. Essential for time-series queries.

Answer First: SQL is written SELECT-first but logically evaluates FROM and filters before projection and ordering.

Memory Map: FROM/JOIN -> WHERE -> GROUP BY -> HAVING -> SELECT/window -> DISTINCT -> ORDER BY -> LIMIT.

VISUAL ANIMATION 1 β€” SQL Execution Order (THE most asked question)

sql
You WRITE queries in this order:            But the DB EXECUTES in this order:
──────────────────────────                   ─────────────────────────────────
SELECT        ← 6                            1. FROM / JOIN      ← load tables
FROM          ← 1                            2. WHERE            ← filter rows
JOIN          ← 2                            3. GROUP BY         ← aggregate
WHERE         ← 3                            4. HAVING           ← filter groups
GROUP BY      ← 4                            5. SELECT           ← pick columns
HAVING        ← 5                            6. DISTINCT         ← dedup
ORDER BY      ← 7                            7. ORDER BY         ← sort
LIMIT         ← 8                            8. LIMIT            ← cap rows

THIS IS WHY:
  ❌  SELECT name AS n, ... ORDER BY n        works (ORDER BY runs AFTER SELECT)
  ❌  SELECT ... WHERE n > 5                  fails (WHERE runs BEFORE SELECT alias)
  ❌  SELECT SUM(x) ... WHERE SUM(x) > 10     fails (aggregate not ready in WHERE)
  βœ…  SELECT SUM(x) ... HAVING SUM(x) > 10    works (HAVING runs AFTER aggregation)

Answer First: A join evaluates candidate row pairs under the ON condition, then preserves unmatched sides according to join type.

Memory Map: Candidate pairs -> ON match -> emit matches -> NULL-extend preserved unmatched rows.

VISUAL ANIMATION 2 β€” How a JOIN actually works (nested loop view)

sql
SELECT c.name, o.order_num
FROM customers c JOIN orders o ON c.id = o.id;

Step-by-step (nested loop):
────────────────────────────

for each row IN customers:         ← outer loop (3 customers)
    for each row IN orders:        ← inner loop (3 orders)
        if c.id == o.id:           ← check match condition
            OUTPUT (c.name, o.order_num)

Iterations:
  (Alice,1) Γ— (1,101) βœ… β†’ EMIT Alice|101
  (Alice,1) Γ— (1,102) βœ… β†’ EMIT Alice|102
  (Alice,1) Γ— (4,103) ❌ β†’ skip
  (Bob,  2) Γ— (1,101) ❌ β†’ skip
  (Bob,  2) Γ— (1,102) ❌ β†’ skip
  (Bob,  2) Γ— (4,103) ❌ β†’ skip
  (Carol,3) Γ— (1,101) ❌ β†’ skip
  (Carol,3) Γ— (1,102) ❌ β†’ skip
  (Carol,3) Γ— (4,103) ❌ β†’ skip

Result: 2 rows emitted.

πŸ’‘ On large tables, this is O(N Γ— M) β€” too slow.
   That's why the DB uses HASH JOIN or MERGE JOIN instead:
   - Hash Join:  build a hash table on smaller side (O(N+M))
   - Merge Join: sort both, merge (O(N log N + M log M))

Answer First: The frame is the subset of ordered partition rows visible to a window aggregate for the current row.

Memory Map: Partition -> order peers -> ROWS/RANGE -> frame bounds -> current-row result.

VISUAL ANIMATION 3 β€” Window Frame (the thing everyone gets wrong)

Window frame = "which rows does this window function SEE?"
Data: [10, 20, 30, 40, 50] (ORDER BY val)
SUM(val) OVER (ORDER BY val)
= SUM(val) OVER (ORDER BY val
ROWS BETWEEN UNBOUNDED PRECEDING
AND CURRENT ROW) ← default frame
(running total)
Row val Frame sees SUM
1 10 [10] 10
2 20 [10,20] 30
3 30 [10,20,30] 60
4 40 [10,20,30,40] 100
5 50 [10,20,30,40,50] 150
SUM(val) OVER (ORDER BY val
ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING) ← sliding window
Row val Frame sees SUM
1 10 [10,20] 30 ← no previous (edge)
2 20 [10,20,30] 60 ← window of 3
3 30 [20,30,40] 90
4 40 [30,40,50] 120
5 50 [40,50] 90 ← no next (edge)
SUM(val) OVER () ← NO ORDER BY, NO PARTITION
Row val Frame sees SUM
1 10 [10,20,30,40,50] 150 ← total (same for all rows)
2 20 [10,20,30,40,50] 150
3 30 [10,20,30,40,50] 150
4 40 [10,20,30,40,50] 150
5 50 [10,20,30,40,50] 150
🧠 KEY: Adding ORDER BY changes the default frame from
"whole partition" to "unbounded preceding→current row."
That's what makes it a running total.

GOTCHAS β€” Weird SQL Errors and Exact Fixes

Answer First: NULL is unknown, so NULL = NULL is unknown rather than true; use IS NULL or a null-safe operator.

Memory Map: Value may be unknown -> use IS NULL -> define null-safe equality only when business semantics require it.

Gotcha 1: NULL = NULL is NOT true

sql
SELECT * FROM t WHERE col = NULL;  -- ← ❌ returns ZERO rows ALWAYS
SELECT * FROM t WHERE col IS NULL; -- ← βœ… correct

Why: In SQL's three-valued logic, NULL = NULL evaluates to NULL (unknown), not TRUE.

Interview trap: "Why does COUNT() != COUNT(col)?" Answer: "COUNT() counts all rows; COUNT(col) counts NON-NULL values of col."

Answer First: Integer operands may truncate a ratio; cast or multiply by a decimal before division.

Memory Map: Numerator -> decimal cast -> NULLIF denominator -> divide -> round only for presentation.

Gotcha 2: Integer division truncates

sql
SELECT 5 / 2;          -- PostgreSQL: 2 (integer division!)
SELECT 5.0 / 2;        -- 2.5 (one operand is decimal)
SELECT CAST(5 AS DECIMAL) / 2;  -- 2.5

Fix: Always cast to DECIMAL/NUMERIC when you want precise division.

Answer First: Alias visibility varies by dialect and logical phase, so group by the expression or use a CTE when portability matters.

Memory Map: Compute expression -> name it in CTE -> group by stable column -> verify dialect rules.

Gotcha 3: GROUP BY and aliases

sql
-- ❌ DOESN'T WORK in most databases:
SELECT EXTRACT(YEAR FROM sale_date) AS yr, SUM(amount)
FROM sales
GROUP BY yr;          -- ← can't use alias 'yr' here in standard SQL

-- βœ… WORKS:
SELECT EXTRACT(YEAR FROM sale_date) AS yr, SUM(amount)
FROM sales
GROUP BY EXTRACT(YEAR FROM sale_date);  -- ← repeat the expression
-- OR use column position:
GROUP BY 1;           -- PostgreSQL allows this

Answer First: LIKE case sensitivity is dialect/collation dependent; ILIKE is a PostgreSQL-style case-insensitive match.

Memory Map: Dialect/collation -> normalize case if portable -> escape wildcards -> consider index impact.

Gotcha 4: LIKE vs ILIKE (case sensitivity)

sql
WHERE name LIKE 'alice%'   -- case-sensitive (no match if name='Alice')
WHERE name ILIKE 'alice%'  -- case-insensitive (PostgreSQL only)
WHERE LOWER(name) LIKE 'alice%'  -- works everywhere

Answer First: Exact COUNT(DISTINCT) can require a large shuffle or sort; approximate only when the accuracy contract allows it.

Memory Map: Cardinality -> exactness requirement -> plan/memory cost -> pre-aggregate or approximate deliberately.

Gotcha 5: COUNT(DISTINCT) is SLOW at scale

sql
SELECT COUNT(DISTINCT user_id) FROM events;  -- slow on 1B rows

Fix at scale: Use APPROX_COUNT_DISTINCT or HyperLogLog (HLL) sketches.

Answer First: A correlated scalar subquery may repeat work per outer row; pre-aggregate once and join when the plan does not decorrelate it.

Memory Map: Inspect plan -> aggregate inner data -> join once -> preserve missing matches with LEFT JOIN.

Gotcha 6: Correlated subquery inside SELECT β€” N+1 problem

sql
-- ❌ BAD: runs a subquery PER ROW of employees
SELECT name, (SELECT name FROM dept WHERE dept.id = e.dept_id) AS dept_name
FROM employees e;

-- βœ… GOOD: single JOIN
SELECT e.name, d.name AS dept_name
FROM employees e JOIN dept d ON e.dept_id = d.id;

Impact: On 1M employees with 100 depts, the bad version does 1M subquery executions vs 1 join.

MOCK INTERVIEW

Answer First: Second-highest salary means the second distinct value unless the interviewer explicitly wants the second row.

Memory Map: Clarify ties -> DENSE_RANK values -> filter rank 2 -> decide global or per group.

Q1: "Find the 2nd highest salary."

❌ BAD ANSWER: SELECT MAX(salary) FROM emp WHERE salary != (SELECT MAX(salary) FROM emp); (works but breaks with ties β€” if 2 people tied for 1st, returns 1st again)

βœ… GOOD ANSWER:

sql
-- Using DENSE_RANK β€” handles ties correctly
SELECT DISTINCT salary
FROM (
    SELECT salary, DENSE_RANK() OVER (ORDER BY salary DESC) AS rk
    FROM employees
) t
WHERE rk = 2;

-- Alternative: LIMIT + OFFSET with DISTINCT
SELECT DISTINCT salary FROM employees
ORDER BY salary DESC
LIMIT 1 OFFSET 1;

Follow-up trap: "What if we want the 2nd highest WITHOUT DENSE_RANK, for a really old database?" Answer: Use correlated subquery:

sql
SELECT MAX(salary) FROM employees
WHERE salary < (SELECT MAX(salary) FROM employees);

Answer First: Consecutive-day streaks use the gaps-and-islands invariant after deduplicating same-day events.

Memory Map: Distinct user/date -> ROW_NUMBER -> date minus sequence -> group -> filter length.

Q2: "Find consecutive days of activity for each user."

βœ… GOOD ANSWER (the classic "Gaps and Islands" pattern):

sql
SELECT user_id, MIN(activity_date) AS streak_start,
                MAX(activity_date) AS streak_end,
                COUNT(*) AS streak_days
FROM (
    SELECT user_id, activity_date,
           activity_date - INTERVAL '1 day' * ROW_NUMBER()
               OVER (PARTITION BY user_id ORDER BY activity_date) AS grp
    FROM user_activity
) t
GROUP BY user_id, grp
HAVING COUNT(*) >= 2;

Explain: "If dates are consecutive, date - row_number() is constant. Grouping on that constant finds streaks."

This pattern ("Gaps & Islands") is asked at EVERY senior SQL interview. Memorize it.

Answer First: Keep one duplicate deterministically with ROW_NUMBER over the key and an explicit recency tie-breaker.

Memory Map: Key -> newest timestamp -> stable secondary key -> rn = 1 -> delete or select safely.

Q3: "Write a query to remove duplicates, keeping the most recent row per user."

❌ BAD ANSWER: "SELECT DISTINCT..." (doesn't handle "most recent" logic)

βœ… GOOD ANSWER:

sql
WITH ranked AS (
    SELECT *, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY updated_at DESC) AS rn
    FROM users
)
SELECT * FROM ranked WHERE rn = 1;

Why ROW_NUMBER not RANK? ROW_NUMBER always gives unique values (1,2,3...) so rn=1 gives exactly one row per user, even with tied timestamps. RANK would return multiple rows on tie.

Answer First: A regression is debugged with evidence: compare plans, row counts, statistics, skew, indexes, and recent changes.

Memory Map: Reproduce -> plan diff -> cardinality/skew -> I/O and waits -> smallest measured fix.

Q4: "Our query used to be fast, now it's slow. How do you debug?"

❌ BAD ANSWER: "I'd add an index."

βœ… GOOD ANSWER:

"Five checks, in order. First, EXPLAIN ANALYZE the query and look for sequential scans, nested loop joins on large tables, or high row-count estimates vs actuals. Second, check if table stats are stale β€” run ANALYZE. Third, look for missing indexes on join/filter columns. Fourth, check if the data volume grew β€” a query that was fast on 1M rows may be slow on 100M without re-tuning. Fifth, look for recent schema changes, new functions, or changed execution plans. I'd compare the current plan vs an older baseline if we have query store enabled."

Answer First: Use a CTE for readable statement-local stages, subquery for a small local expression, and temp table for reusable/materialized intermediate data.

Memory Map: Scope -> reuse count -> optimizer behavior -> indexing/statistics need -> cleanup.

Q5: "When would you use a CTE vs subquery vs temporary table?"

βœ… GOOD ANSWER:

βœ… Pro Tip
"CTE for readability when the logic has multiple steps and is referenced once or twice in the main query β€” it's essentially a named subquery. Subquery (inline) for trivial single-value or single-use cases. Temporary table when I need to reuse the intermediate result across MULTIPLE queries, or when the intermediate result is expensive to compute and indexed access would help. Also temp table when the CTE isn't being inlined by the optimizer and performance is suffering β€” some databases materialize CTEs which can be slow."

Follow-up: "What's a recursive CTE?" Answer: "A CTE that references itself β€” used for hierarchies (org charts, BOM, graph traversal). Has a base case and recursive case joined by UNION ALL."

FINAL READINESS CHECKLIST

If you can do all of these, you don't need videos:

  • Explain WHERE vs HAVING with execution order
  • Draw all 4 JOIN types on a whiteboard with sample data
  • Write RANK, DENSE_RANK, ROW_NUMBER in one query and predict output
  • Write the "LEFT JOIN + WHERE IS NULL" anti-join from memory
  • Write Gaps & Islands (consecutive days) from memory
  • Explain why COUNT(*) β‰  COUNT(col) when col has NULLs
  • Explain SQL execution order in under 30 seconds
  • Name 3 reasons a query got slow and how to debug each
  • Answer "how is UNION different from UNION ALL" in one line
  • Write "2nd highest salary" TWO different ways (DENSE_RANK + correlated subquery)

If yes β†’ you're SQL-interview ready. No videos needed.

Advanced

Canonical SQL Question Index

#

Canonical SQL Question Index

Question-bank contract

Use stable Q numbers. The index preserves company, pattern, difficulty, and solved status, while each answer lives at one exact concept or drill anchor.

Pattern routing table

PatternMeaningKey SQL
P1Ranking / Top-N Per GroupROW_NUMBER, RANK, DENSE_RANK + PARTITION BY
P2Running Totals / CumulativeSUM() OVER (ORDER BY)
P3LAG / LEAD β€” Row-over-RowLAG(), LEAD()
P4Gaps & Islandsdate - ROW_NUMBER() island grouping
P5SessionizationLAG() gap detection + cumulative SUM as session_id
P6DeduplicationROW_NUMBER() PARTITION BY, keep rank = 1
P7Self-JoinSame table aliased twice
P8Cross Join β€” CombinationsCROSS JOIN for Cartesian product
P9Market Basket / Co-occurrenceSelf-join on order_id, item1 < item2
P10Conditional Aggregation / PivotSUM(CASE WHEN ...)
P11Cohort / Retention AnalysisMIN(event_date) as cohort + date offset
P12Date ArithmeticDATEDIFF, DATE_TRUNC, INTERVAL
P13Recursive CTEWITH RECURSIVE anchor + recursive step
P14Median / PercentilePERCENTILE_CONT or ROW_NUMBER median trick
P15Funnel AnalysisMulti-stage COUNT DISTINCT + conversion rate

Quick-recall aliases

These are navigation aliases only; the complete explanation stays with its canonical owner.

Window-function aliases (P1-P3)

Alias P1: Ranking / Top-N Per Group

Alias owner: Open the canonical pattern.

Recall cue: ROW_NUMBER, RANK, DENSE_RANK + PARTITION BY.

Alias P2: Running Totals / Cumulative

Alias owner: Open the canonical pattern.

Recall cue: SUM() OVER (ORDER BY).

Alias P3: LAG / LEAD β€” Row-over-Row

Alias owner: Open the canonical pattern.

Recall cue: LAG(), LEAD().

Hard-pattern aliases (P4-P6)

Alias P4: Gaps & Islands

Alias owner: Open the canonical pattern.

Recall cue: date - ROW_NUMBER() island grouping.

Alias P5: Sessionization

Alias owner: Open the canonical pattern.

Recall cue: LAG() gap detection + cumulative SUM as session_id.

Alias P6: Deduplication

Alias owner: Open the canonical pattern.

Recall cue: ROW_NUMBER() PARTITION BY, keep rank = 1.

Join-pattern aliases (P7-P9)

Alias P7: Self-Join

Alias owner: Open the canonical pattern.

Recall cue: Same table aliased twice.

Alias P8: Cross Join β€” Combinations

Alias owner: Open the canonical pattern.

Recall cue: CROSS JOIN for Cartesian product.

Alias P9: Market Basket / Co-occurrence

Alias owner: Open the canonical pattern.

Recall cue: Self-join on order_id, item1 < item2.

Aggregation aliases (P10-P12)

Alias P10: Conditional Aggregation / Pivot

Alias owner: Open the canonical pattern.

Recall cue: SUM(CASE WHEN ...).

Alias P11: Cohort / Retention Analysis

Alias owner: Open the canonical pattern.

Recall cue: MIN(event_date) as cohort + date offset.

Alias P12: Date Arithmetic

Alias owner: Open the canonical pattern.

Recall cue: DATEDIFF, DATE_TRUNC, INTERVAL.

Advanced-analysis aliases (P13-P15)

Alias P13: Recursive CTE

Alias owner: Open the canonical pattern.

Recall cue: WITH RECURSIVE anchor + recursive step.

Alias P14: Median / Percentile

Alias owner: Open the canonical pattern.

Recall cue: PERCENTILE_CONT or ROW_NUMBER median trick.

Alias P15: Funnel Analysis

Alias owner: Open the canonical pattern.

Recall cue: Multi-stage COUNT DISTINCT + conversion rate.

Question bank

Q-SQL-001: Top 2 highest-grossing products within each category in 2022

Company: Amazon
Pattern: P1
Difficulty: Medium
Solved in legacy guide: βœ“

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L38.

Q-SQL-002: Top 3 salaries in each department

Company: FAANG Classic
Pattern: P1
Difficulty: Medium
Solved in legacy guide: βœ“

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L39.

Q-SQL-003: Email activity rank per user (sent + received + spam, dense ranked)

Company: Google
Pattern: P1
Difficulty: Medium
Solved in legacy guide: βœ“

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L40.

Q-SQL-004: Top 2 users per company with most calls (maintain ties)

Company: RingCentral
Pattern: P1
Difficulty: Medium
Solved in legacy guide: No

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L41.

Q-SQL-005: Most-used vehicle type in past year (excluding cancelled rides)

Company: Uber
Pattern: P1
Difficulty: Easy-Med
Solved in legacy guide: No

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L42.

Q-SQL-006: Olympic swimmers who won ONLY gold medals β€” count golds each

Company: Amazon
Pattern: P1
Difficulty: Medium
Solved in legacy guide: No

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L43.

Q-SQL-007: Nominee who won the most Oscars

Company: Netflix
Pattern: P1
Difficulty: Easy
Solved in legacy guide: No

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L44.

Q-SQL-008: Top 10 users by total distance across all rides

Company: Lyft
Pattern: P1
Difficulty: Medium
Solved in legacy guide: No

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L45.

Q-SQL-009: Top 5 product pairs most frequently purchased together

Company: Interview Query
Pattern: P1+P9
Difficulty: Hard
Solved in legacy guide: No

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L46.

Q-SQL-010: Top 3 departments by average salary

Company: Classic
Pattern: P1
Difficulty: Easy
Solved in legacy guide: No

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L47.

Q-SQL-011: Cumulative merchant transaction balance, reset at start of each month

Company: Visa
Pattern: P2
Difficulty: Hard
Solved in legacy guide: βœ“

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L48.

Q-SQL-012: Cumulative users added daily, reset at start of each month

Company: Interview Query
Pattern: P2
Difficulty: Hard
Solved in legacy guide: No

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L49.

Q-SQL-013: Running total revenue by product category in 2022

Company: Generic
Pattern: P2
Difficulty: Medium
Solved in legacy guide: No

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L50.

Q-SQL-014: Cumulative salary of employee for 3 months excluding most recent (LeetCode Hard)

Company: LeetCode
Pattern: P2
Difficulty: Hard
Solved in legacy guide: No

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L51.

Q-SQL-015: Total server fleet uptime across overlapping maintenance windows

Company: Amazon
Pattern: P2+P12
Difficulty: Hard
Solved in legacy guide: No

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L52.

Q-SQL-016: Month-over-month change in revenue for 2019

Company: Interview Query
Pattern: P2+P3
Difficulty: Medium
Solved in legacy guide: No

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L53.

Q-SQL-017: Duplicate payments: same merchant+card+amount within 10 minutes

Company: Stripe
Pattern: P3
Difficulty: Hard
Solved in legacy guide: βœ“

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L54.

Q-SQL-018: Average delay between sign-up and 2nd ride (in-the-moment users)

Company: Uber
Pattern: P3
Difficulty: Hard
Solved in legacy guide: No

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L55.

Q-SQL-019: Twitter rolling 3-day average number of tweets per user

Company: Twitter
Pattern: P2
Difficulty: Medium
Solved in legacy guide: βœ“

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L56.

Q-SQL-020: % buyers who purchased AirPods directly after iPhone (next purchase)

Company: Apple
Pattern: P3
Difficulty: Hard
Solved in legacy guide: βœ“

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L57.

Q-SQL-021: Countries that moved higher in comment ranking Dec→Jan

Company: Facebook
Pattern: P3
Difficulty: Hard
Solved in legacy guide: No

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L58.

Q-SQL-022: 3 largest month-over-month call declines by company

Company: RingCentral
Pattern: P3
Difficulty: Hard
Solved in legacy guide: No

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L59.

Q-SQL-023: Users whose 2nd purchase was within 48 hours of 1st

Company: Amazon
Pattern: P3
Difficulty: Medium
Solved in legacy guide: No

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L60.

Q-SQL-024: Rows where purchase amount grew vs previous transaction

Company: Classic
Pattern: P3
Difficulty: Medium
Solved in legacy guide: No

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L61.

Q-SQL-025: Top 3 users with longest continuous login streak

Company: StrataScratch
Pattern: P4
Difficulty: Hard
Solved in legacy guide: βœ“

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L62.

Q-SQL-026: Employees who worked consecutive days for at least 5 days straight

Company: Classic
Pattern: P4
Difficulty: Hard
Solved in legacy guide: No

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L63.

Q-SQL-027: Periods of inactivity for each supplier β€” longest gap between orders

Company: Classic
Pattern: P4
Difficulty: Hard
Solved in legacy guide: No

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L64.

Q-SQL-028: Users who placed orders every week for 4+ consecutive weeks

Company: LeetCode variant
Pattern: P4
Difficulty: Hard
Solved in legacy guide: No

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L65.

Q-SQL-029: Stocks with consecutive days of price increases (3+ in a row)

Company: Bloomberg variant
Pattern: P4
Difficulty: Hard
Solved in legacy guide: No

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L66.

Q-SQL-030: Date ranges when a server was continuously online

Company: Amazon/Google
Pattern: P4
Difficulty: Hard
Solved in legacy guide: No

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L67.

Q-SQL-031: Users with no activity for 30+ days at any point in history

Company: PracticeWindowFunctions
Pattern: P4
Difficulty: Hard
Solved in legacy guide: No

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L68.

Q-SQL-032: Assign session_id to each event (new session = 30 min inactivity)

Company: Mode Analytics
Pattern: P5
Difficulty: Hard
Solved in legacy guide: βœ“

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L69.

Q-SQL-033: Average session duration per user (session = 30 min gap)

Company: Google
Pattern: P5
Difficulty: Hard
Solved in legacy guide: No

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L70.

Q-SQL-034: Users with highest number of sessions in a given month

Company: Facebook
Pattern: P5
Difficulty: Hard
Solved in legacy guide: No

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L71.

Q-SQL-035: Sessions that resulted in a purchase within the same session

Company: Amazon
Pattern: P5
Difficulty: Hard
Solved in legacy guide: No

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L72.

Q-SQL-036: Users whose first session was as viewer but later became streamer

Company: Twitch
Pattern: P5
Difficulty: Medium
Solved in legacy guide: No

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L73.

Q-SQL-037: Count duplicate customer_id entries from ETL bug

Company: Amazon
Pattern: P6
Difficulty: Easy
Solved in legacy guide: No

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L74.

Q-SQL-038: From CDC table, keep most recent record per customer_id

Company: Databricks
Pattern: P6
Difficulty: Medium
Solved in legacy guide: βœ“

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L75.

Q-SQL-039: Duplicate transaction_ids β€” keep record with highest amount

Company: Stripe variant
Pattern: P6
Difficulty: Medium
Solved in legacy guide: No

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L76.

Q-SQL-040: Users with more than one account (same email, different user_ids)

Company: Facebook
Pattern: P6
Difficulty: Easy
Solved in legacy guide: No

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L77.

Q-SQL-041: Deduplicate user_profiles keeping lowest user_id per email

Company: Meta
Pattern: P6
Difficulty: Medium
Solved in legacy guide: No

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L78.

Q-SQL-042: Employees who earn more than their direct manager

Company: Amazon/Microsoft
Pattern: P7
Difficulty: Medium
Solved in legacy guide: βœ“

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L79.

Q-SQL-043: Friend recommendations: users who attend 2+ same events, not already friends

Company: Facebook
Pattern: P7
Difficulty: Hard
Solved in legacy guide: No

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L80.

Q-SQL-044: Pairs of students in same class who scored within 5 points of each other

Company: Academic
Pattern: P7
Difficulty: Medium
Solved in legacy guide: No

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L81.

Q-SQL-045: Cheapest two-stop routes between any origin-destination pair

Company: Delta Airlines
Pattern: P7
Difficulty: Hard
Solved in legacy guide: No

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L82.

Q-SQL-046: Players who beat same opponent at least twice

Company: Sports
Pattern: P7
Difficulty: Medium
Solved in legacy guide: No

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L83.

Q-SQL-047: Managers with at least 5 direct reports

Company: LeetCode
Pattern: P7
Difficulty: Easy
Solved in legacy guide: No

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L84.

Q-SQL-048: All possible size-color combinations for new product line

Company: Generic retail
Pattern: P8
Difficulty: Easy
Solved in legacy guide: No

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L85.

Q-SQL-049: Full date Γ— product grid, LEFT JOIN sales to find zero-sales days

Company: Amazon/Walmart
Pattern: P8
Difficulty: Medium
Solved in legacy guide: No

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L86.

Q-SQL-050: Full round-robin tournament schedule from teams table

Company: Sports / McKinsey
Pattern: P8
Difficulty: Medium
Solved in legacy guide: No

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L87.

Q-SQL-051: All 3-topping pizza combinations with total cost

Company: McKinsey
Pattern: P8
Difficulty: Medium
Solved in legacy guide: βœ“

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L88.

Q-SQL-052: Full region Γ— category grid for monthly report (zero-fill)

Company: Retail DE
Pattern: P8
Difficulty: Medium
Solved in legacy guide: No

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L89.

Q-SQL-053: Top 5 pairs of products most frequently bought together

Company: Interview Query
Pattern: P9
Difficulty: Hard
Solved in legacy guide: βœ“

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L90.

Q-SQL-054: Product most commonly bought alongside Product X

Company: Amazon/Instacart
Pattern: P9
Difficulty: Medium
Solved in legacy guide: No

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L91.

Q-SQL-055: Product pairs where co-purchase count > 100 with lift score

Company: E-commerce
Pattern: P9
Difficulty: Hard
Solved in legacy guide: No

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L92.

Q-SQL-056: Menu item pairs ordered together more than 30% of the time

Company: Swiggy
Pattern: P9
Difficulty: Hard
Solved in legacy guide: No

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L93.

Q-SQL-057: Downloads for paying vs non-paying users by date (filter where non-paying > paying)

Company: Microsoft
Pattern: P10
Difficulty: Medium
Solved in legacy guide: βœ“

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L94.

Q-SQL-058: Click-through rate: 100 Γ— clicks / impressions per app

Company: Facebook
Pattern: P10
Difficulty: Medium
Solved in legacy guide: βœ“

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L95.

Q-SQL-059: Pivot monthly revenue by product category (categories become columns)

Company: Walmart
Pattern: P10
Difficulty: Medium
Solved in legacy guide: No

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L96.

Q-SQL-060: Users active on exactly 3 out of past 7 days

Company: Facebook/Google
Pattern: P10
Difficulty: Medium
Solved in legacy guide: No

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L97.

Q-SQL-061: Orders placed per quarter (Q1,Q2,Q3,Q4) as separate columns per user

Company: Amazon
Pattern: P10
Difficulty: Medium
Solved in legacy guide: No

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L98.

Q-SQL-062: Twitch users who are both streamers and viewers β€” count sessions per type

Company: Twitch
Pattern: P10
Difficulty: Medium
Solved in legacy guide: No

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L99.

Q-SQL-063: Monthly active users in July 2022 (active in BOTH June and July)

Company: Facebook
Pattern: P11
Difficulty: Hard
Solved in legacy guide: βœ“

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L100.

Q-SQL-064: Retention rate of monthly sign-up cohort for months 1, 2, and 3

Company: Interview Query
Pattern: P11
Difficulty: Hard
Solved in legacy guide: No

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L101.

Q-SQL-065: Day-7 retention rate for January 2024 sign-ups

Company: Google/Facebook
Pattern: P11
Difficulty: Hard
Solved in legacy guide: No

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L102.

Q-SQL-066: Weekly sign-up cohort: % who made 2nd purchase within 30 days

Company: Amazon
Pattern: P11
Difficulty: Hard
Solved in legacy guide: No

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L103.

Q-SQL-067: D1, D7, D30 retention side-by-side per monthly sign-up cohort

Company: Meta
Pattern: P11
Difficulty: Hard
Solved in legacy guide: No

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L104.

Q-SQL-068: How unsubscribes affect login rates over 4 weeks after event

Company: Interview Query
Pattern: P11
Difficulty: Hard
Solved in legacy guide: No

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L105.

Q-SQL-069: Days between first and last post of year per user (at least 2 posts)

Company: Facebook
Pattern: P12
Difficulty: Medium
Solved in legacy guide: βœ“

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L106.

Q-SQL-070: Average hours between order placement and delivery (2023)

Company: Amazon
Pattern: P12
Difficulty: Easy
Solved in legacy guide: No

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L107.

Q-SQL-071: Employees at company 5+ years but never promoted

Company: HR
Pattern: P12
Difficulty: Medium
Solved in legacy guide: No

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L108.

Q-SQL-072: % incomplete orders and revenue lost in past 90 days

Company: Uber
Pattern: P12
Difficulty: Medium
Solved in legacy guide: No

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L109.

Q-SQL-073: Subscription renewals more than 7 days late

Company: PayPal/Stripe
Pattern: P12
Difficulty: Medium
Solved in legacy guide: No

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L110.

Q-SQL-074: All employees reporting directly/indirectly to manager_id = 5

Company: Amazon/Microsoft
Pattern: P13
Difficulty: Hard
Solved in legacy guide: βœ“

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L111.

Q-SQL-075: Total subordinate count (direct + indirect) for each manager

Company: Google
Pattern: P13
Difficulty: Hard
Solved in legacy guide: No

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L112.

Q-SQL-076: Product categories and all subcategories recursively

Company: E-commerce
Pattern: P13
Difficulty: Hard
Solved in legacy guide: No

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L113.

Q-SQL-077: Full dependency chain for a given task (task depends on task)

Company: Project Mgmt
Pattern: P13
Difficulty: Hard
Solved in legacy guide: No

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L114.

Q-SQL-078: Generate date spine 2024-01-01 to 2024-12-31 using recursive CTE

Company: DE Pattern
Pattern: P13
Difficulty: Medium
Solved in legacy guide: No

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L115.

Q-SQL-079: All reachable destinations from an origin city (multi-hop flights)

Company: Delta Airlines
Pattern: P13
Difficulty: Hard
Solved in legacy guide: No

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L116.

Q-SQL-080: Median number of searches per user from frequency distribution table

Company: Google
Pattern: P14
Difficulty: Hard
Solved in legacy guide: βœ“

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L117.

Q-SQL-081: Median salary of employees in each department

Company: LeetCode Hard
Pattern: P14
Difficulty: Hard
Solved in legacy guide: No

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L118.

Q-SQL-082: 90th percentile response time per API endpoint in past week

Company: Engineering
Pattern: P14
Difficulty: Medium
Solved in legacy guide: No

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L119.

Q-SQL-083: Divide users into 4 quartiles by total purchase amount 2023

Company: Retail/Amazon
Pattern: P14
Difficulty: Medium
Solved in legacy guide: βœ“

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L120.

Q-SQL-084: Median days between 1st and 2nd purchase for customers with 2+ purchases

Company: E-commerce
Pattern: P14
Difficulty: Hard
Solved in legacy guide: No

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L121.

Q-SQL-085: Count of users at each funnel stage + step-to-step conversion rate

Company: TikTok/Stripe
Pattern: P15
Difficulty: Hard
Solved in legacy guide: βœ“

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L122.

Q-SQL-086: Signup-to-activation rate (confirmed phone / total signed up)

Company: TikTok
Pattern: P15
Difficulty: Medium
Solved in legacy guide: βœ“

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L123.

Q-SQL-087: Drop-off rate at each step of onboarding funnel

Company: DoorDash/Instacart
Pattern: P15
Difficulty: Hard
Solved in legacy guide: No

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L124.

Q-SQL-088: Click-through-to-conversion rate per ad campaign

Company: Google Ads
Pattern: P15
Difficulty: Hard
Solved in legacy guide: No

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L125.

Q-SQL-089: Funnel step with highest drop-off for new users in first 7 days

Company: Facebook/Uber
Pattern: P15
Difficulty: Hard
Solved in legacy guide: No

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L126.

Q-SQL-090: Free trial β†’ paid conversion rate changes across monthly cohorts

Company: SaaS/Stripe
Pattern: P15
Difficulty: Hard
Solved in legacy guide: No

Answer owner: Open the exact canonical drill.

Alternate source wording: SQL_QUESTION_BANK.md#L0.

How to extend this index

Assign the next stable Q number, record the question, company, pattern, difficulty, and solved state, then add the solution under the owning pattern. Never renumber an existing question.

Legacy placeholder format

Q91 | question text | company | P? | Medium/Hard | solved?

Classification-inbox contract

Unclassified interview questions remain pending until their output grain and canonical P1-P15 pattern are identified.

90 seconds

Practice sprint

Close the atlas. Rebuild the map.

Name the path from API to files, then explain where shuffle, skew, and serialization enter the system.

Open interview prompts