WHERE filters individual rows before grouping and aggregation. HAVING filters groups after aggregation, so it can reference aggregate functions that WHERE cannot.
SELECT dept, COUNT(*) AS n
FROM employees
WHERE active = true -- row filter, before grouping
GROUP BY dept
HAVING COUNT(*) > 5; -- group filter, after aggregation
Follow-ups
Can you use a column alias in WHERE? No β the SELECT list isn't resolved yet at that point.
Where does QUALIFY fit? It filters window-function results, conceptually after HAVING.
SQL-B-02BASIC
Explain the different types of JOIN.
Join
Returns
INNER
Only rows with a match in both tables
LEFT
All left rows; NULLs where no right match
RIGHT
All right rows; NULLs where no left match
FULL OUTER
All rows from both; NULLs on either side where no match
CROSS
Cartesian product β every left row Γ every right row
A SELF join is just a table joined to itself (aliased twice) β used for hierarchies like employeeβmanager.
Follow-ups
How do you find rows in A with no match in B? A LEFT JOIN β¦ WHERE b.key IS NULL (an anti-join).
SQL-B-03BASIC
What is the logical order of execution of a SELECT query?
Not the order you write it. The engine logically evaluates: FROM β WHERE β GROUP BY β HAVING β SELECT β DISTINCT β ORDER BY β LIMIT.
This one fact explains most beginner confusion: why an alias defined in SELECT can't be used in WHERE (WHERE runs first), but can be used in ORDER BY (which runs last).
Follow-ups
Why can ORDER BY use a SELECT alias but GROUP BY often can't? Because ORDER BY is evaluated after SELECT; GROUP BY before it.
SQL-B-04BASIC
How does NULL behave, and how do you handle it?
NULL means "unknown", not zero or empty. Any comparison with it yields unknown, so x = NULL is never true β you must use IS NULL / IS NOT NULL.
COALESCE(a, b, 0) returns the first non-null value.
Aggregates like SUM/AVG ignore NULLs; COUNT(col) ignores them but COUNT(*) does not.
TrapNOT IN against a subquery that returns even one NULL returns zero rows for everything. Use NOT EXISTS instead.
SQL-B-05BASIC
UNION vs UNION ALL β what's the difference and cost?
UNION removes duplicate rows, which forces a de-duplication sort/hash over the whole result β real cost on large sets. UNION ALL simply concatenates and is much cheaper.
Default to UNION ALL unless you specifically need duplicates removed. Interviewers listen for this β reaching for UNION by habit is a common performance smell.
Follow-ups
What do INTERSECT and EXCEPT do? Rows in both sets; rows in the first but not the second.
SQL-B-06BASIC
What is the difference between DELETE, TRUNCATE and DROP?
Removes
Rollback?
Speed
DELETE
Rows (with a WHERE)
Yes (logged)
Slow, row by row
TRUNCATE
All rows, keeps table
Usually no
Fast (deallocates)
DROP
The whole table + structure
No
Fast
Follow-ups
Which resets an identity/auto-increment? TRUNCATE typically does; DELETE does not.
SQL-B-07BASIC
What's the difference between COUNT(*), COUNT(col) and COUNT(DISTINCT col)?
COUNT(*) β all rows, including those with NULLs.
COUNT(col) β rows where col is not NULL.
COUNT(DISTINCT col) β distinct non-NULL values.
A quick way to detect duplicates on a key: compare COUNT(*) with COUNT(DISTINCT key).
SQL-B-08BASIC
What are primary keys, foreign keys and unique constraints?
Primary key β uniquely identifies a row; not null, one per table.
Unique β enforces uniqueness on a column/set, but allows (typically one) NULL.
Foreign key β references a key in another table, enforcing referential integrity.
Trap Some analytical warehouses (e.g. Snowflake, Redshift) don't enforce PK/FK/UNIQUE β they're optimiser metadata only. Never rely on them for correctness there.
SQL-B-09BASIC
What does DISTINCT do, and what does it cost?
DISTINCT removes duplicate rows from the result β the whole row, not one column. It costs a sort or hash over the full result set, so on large data it's expensive.
Rule of thumb: if you find yourself adding DISTINCT to "fix" duplicates, first ask why duplicates exist β it's often a join problem being masked.
SQL-B-10BASIC
How do ORDER BY, LIMIT and OFFSET work together?
SELECT * FROM products
ORDER BY price DESC, product_id -- tie-breaker!
LIMIT 10 OFFSET 20; -- rows 21β30
Always add a deterministic tie-breaker column: without it, rows with equal price can swap between pages and paging becomes unstable.
Follow-ups
Why is big OFFSET slow? The engine still computes and discards all skipped rows β keyset pagination (WHERE id > last_seen) scales better.
SQL-B-11BASIC
Explain LIKE and its wildcards.
% matches any sequence of characters (including none); _ matches exactly one. 'A%' = starts with A; '%son' = ends with son; '_a%' = second letter is a.
Performance note: a leading wildcard ('%thing') defeats index/pruning use β the engine must scan.
Follow-ups
Case-insensitive match? ILIKE (Postgres/Snowflake) or LOWER(col) LIKE β¦.
SQL-B-12BASIC
BETWEEN and IN β behaviour and the date trap.
IN (a,b,c) is shorthand for chained ORs. BETWEEN x AND y is inclusive on both ends.
The trap is timestamps: BETWEEN '2026-01-01' AND '2026-01-31' misses everything after midnight on the 31st. Safer pattern for dates with time:
WHERE dt >= '2026-01-01' AND dt < '2026-02-01'
SQL-B-13BASIC
What are the rules of GROUP BY?
Every column in the SELECT list must be either (a) in the GROUP BY, or (b) inside an aggregate function. That's the whole contract β one output row per distinct combination of the grouped columns.
SELECT dept, job, AVG(salary) -- dept, job grouped; salary aggregated
FROM employees
GROUP BY dept, job;
Follow-ups
Why does MySQL sometimes allow ungrouped columns? Legacy mode β it picks an arbitrary value. Treat it as a bug source, not a feature.
SQL-B-14BASIC
How does CASE WHEN work? Give two common uses.
-- 1) bucketing
SELECT CASE WHEN amount >= 1000 THEN 'large'
WHEN amount >= 100 THEN 'medium'
ELSE 'small' END AS size_band, COUNT(*)
FROM orders GROUP BY 1;
-- 2) conditional aggregation (mini-pivot)
SELECT SUM(CASE WHEN status='paid' THEN amount ELSE 0 END) AS paid,
SUM(CASE WHEN status='open' THEN amount ELSE 0 END) AS open_amt
FROM invoices;
Conditions are evaluated top-down; the first match wins. With no ELSE, non-matches return NULL.
SQL-B-15BASIC
Name the string functions you use most and what they do.
CONCAT / || β join strings (mind NULL: in standard SQL, NULL || x = NULL).
SUBSTRING(s, start, len), LEFT, RIGHT β slicing.
UPPER / LOWER β case normalisation before comparing.
REPLACE(s, from, to), SPLIT_PART(s, ',', 2) β cleanup and parsing.
LENGTH, POSITION/CHARINDEX β measuring and locating.
SQL-B-16BASIC
And the date functions?
CURRENT_DATE / CURRENT_TIMESTAMP β now.
DATE_TRUNC('month', dt) β snap to period start; the backbone of time-series grouping.
DATEADD / DATEDIFF (or interval arithmetic) β shifting and gaps.
EXTRACT(DOW/YEAR/β¦ FROM dt) β pulling parts.
TO_DATE / TO_CHAR β parsing and formatting.
Grouping by DATE_TRUNC('month', dt) beats grouping by YEAR(dt), MONTH(dt) β one sortable column instead of two.
SQL-B-17BASIC
CHAR vs VARCHAR vs TEXT; and why numeric types matter.
CHAR(n) pads to fixed length (rarely what you want); VARCHAR(n) stores up to n; TEXT is unbounded. For numbers: INT/BIGINT for counts and keys, DECIMAL(p,s) for money β never FLOAT for money, because binary floats can't represent 0.1 exactly and pennies drift.
Follow-ups
What happens comparing '10' to 10? An implicit cast β which can silently disable index/partition use on the cast side.
SQL-B-18BASIC
What is a view, and when would you use one?
A view is a saved query that behaves like a table. Uses: hide complexity behind a clean interface, enforce security (grant on the view, not the tables), and centralise a definition so "active customer" is written once.
A plain view stores no data β it re-runs on every query. A materialized view stores results and must be refreshed; faster reads, staleness and maintenance cost.
SQL-B-19BASIC
What is a subquery, and where can one appear?
A query nested inside another. Three placements:
WHERE β filtering: WHERE dept_id IN (SELECT id FROM depts WHERE region='EU')
FROM β a derived table you then join or aggregate.
SELECT β a scalar per row (use sparingly; often better as a join or window).
SQL-B-20BASIC
How do you insert, update and delete safely?
INSERT INTO t (a, b) VALUES (1, 'x'); -- name columns, always
UPDATE t SET b = 'y' WHERE id = 7; -- WHERE first, then SET
DELETE FROM t WHERE created < '2020-01-01';
The habit that saves careers: run the WHERE as a SELECT first to see exactly which rows you're about to change, and wrap risky changes in a transaction so you can roll back.
SQL-B-21BASIC
What is SQL, and how is it different from MySQL?
SQL (Structured Query Language) is the language β a standard for defining, querying and manipulating relational data. MySQL is a database product that speaks SQL, just like PostgreSQL, SQL Server, Oracle and Snowflake do.
Every product implements the standard plus its own extensions β which is why LIMIT works in MySQL but SQL Server uses TOP. In an interview, saying "the concept is standard, the syntax detail varies by engine" is exactly the right frame.
SQL-B-22BASIC
Explain DDL, DML, DCL and TCL with examples.
Category
Purpose
Commands
DDL β Definition
Structure of objects
CREATE, ALTER, DROP, TRUNCATE
DML β Manipulation
The data itself
SELECT, INSERT, UPDATE, DELETE, MERGE
DCL β Control
Permissions
GRANT, REVOKE
TCL β Transactions
Units of work
BEGIN, COMMIT, ROLLBACK, SAVEPOINT
A detail that impresses: DDL is auto-committing in most engines β you usually cannot roll back a DROP TABLE, which is why it deserves more fear than a DELETE.
SQL-B-23BASIC
Walk through all the constraint types.
CREATE TABLE employees (
emp_id INT PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
dept_id INT REFERENCES departments(dept_id), -- FOREIGN KEY
salary DECIMAL(10,2) CHECK (salary > 0),
hire_date DATE DEFAULT CURRENT_DATE,
status VARCHAR(10) NOT NULL DEFAULT 'active'
);
NOT NULL β value required.
UNIQUE β no duplicates (NULLs usually allowed).
PRIMARY KEY β UNIQUE + NOT NULL, one per table; the row's identity.
FOREIGN KEY β value must exist in the referenced table.
CHECK β an arbitrary condition every row must satisfy.
DEFAULT β the value used when none is supplied.
Constraints are your first line of data quality β they stop bad data at the door instead of finding it in a report.
SQL-B-24BASIC
What is a composite primary key, and when do you use one?
A primary key made of two or more columns whose combination must be unique:
CREATE TABLE order_items (
order_id INT,
product_id INT,
quantity INT NOT NULL,
PRIMARY KEY (order_id, product_id)
);
Neither column alone is unique β one order has many products, one product appears in many orders β but the pair identifies exactly one line. Natural fit for junction/bridge tables in many-to-many relationships.
Follow-ups
Composite key vs adding a surrogate id? Surrogate simplifies foreign keys pointing here; composite documents the true grain. Many teams use both: surrogate PK + unique constraint on the pair.
SQL-B-25BASIC
Explain foreign key actions: CASCADE, SET NULL, RESTRICT.
They define what happens to child rows when the parent is deleted or its key updated:
FOREIGN KEY (dept_id) REFERENCES departments(dept_id)
ON DELETE CASCADE -- delete the children too
ON UPDATE CASCADE;
CASCADE β propagate the delete/update to children. Convenient, dangerous: one parent delete can silently remove thousands of rows.
SET NULL β orphan the children by nulling the FK (column must be nullable).
RESTRICT / NO ACTION β refuse the delete while children exist. The safe default.
Production advice worth saying: prefer RESTRICT and delete deliberately β accidental cascades are a classic data-loss story.
SQL-B-26BASIC
How do auto-incrementing IDs work across databases?
-- MySQL
id INT AUTO_INCREMENT PRIMARY KEY
-- SQL Server
id INT IDENTITY(1,1) PRIMARY KEY
-- Postgres
id BIGINT GENERATED ALWAYS AS IDENTITY
-- Oracle / standard
id NUMBER DEFAULT seq.NEXTVAL
Two behaviours to know: the counter does not reset on DELETE (only TRUNCATE typically resets it), and gaps are normal β rolled-back inserts consume numbers. Never build logic that assumes IDs are consecutive.
SQL-B-27BASIC
Show the common ALTER TABLE operations.
ALTER TABLE emp ADD COLUMN phone VARCHAR(20);
ALTER TABLE emp DROP COLUMN fax;
ALTER TABLE emp RENAME COLUMN nm TO name;
ALTER TABLE emp ALTER COLUMN salary TYPE DECIMAL(12,2); -- Postgres syntax
ALTER TABLE emp ADD CONSTRAINT uq_email UNIQUE (email);
ALTER TABLE emp RENAME TO employees;
The production caveat: on huge tables some ALTERs rewrite the whole table and lock it. Adding a nullable column is usually instant; adding NOT NULL with a default, or changing a type, may not be. Always check before running one on a big production table.
SQL-B-28BASIC
AND, OR, NOT β how does precedence work in WHERE?
NOT binds tightest, then AND, then OR. This query does not do what it looks like:
WHERE region = 'EU' OR region = 'UK' AND status = 'active'
-- actually means: region='EU' OR (region='UK' AND status='active')
-- EU rows of ANY status leak through!
Always parenthesise mixed AND/OR. This is one of the most common real-world bug patterns in report SQL, and interviewers love it as a spot-the-bug question.
SQL-B-29BASIC
Is NULL the same as an empty string or zero?
No β three different things:
NULL = value unknown/absent. col = '' will NOT find it.
'' = a known value that happens to be empty text.
0 = a known numeric value.
-- find both empty-ish states explicitly:
WHERE col IS NULL OR TRIM(col) = ''
One famous exception to name: Oracle historically treats '' as NULL β a portability trap.
SQL-B-30BASIC
Tour the aggregate functions and their NULL behaviour.
Function
Returns
NULLs
COUNT(*)
row count
counted
COUNT(col)
non-null count
ignored
SUM / AVG
total / mean
ignored
MIN / MAX
extremes
ignored
The trap this creates: AVG(col) averages only the non-null rows. If half your rows are NULL, AVG(col) and SUM(col)/COUNT(*) give different answers β decide which the business actually means, and use AVG(COALESCE(col,0)) when NULL should count as zero.
SQL-B-31BASIC
GROUP BY on multiple columns β what does one output row represent?
SELECT region, product, SUM(revenue)
FROM sales
GROUP BY region, product;
One row per distinct combination β (EU, Widget), (EU, Gadget), (US, Widget)β¦ The mental model that prevents mistakes: GROUP BY defines the grain of your output. Say the grain out loud β "one row per region per product" β and questions like "can I add customer to the SELECT?" answer themselves (no, unless you add it to the grain or aggregate it).
SQL-B-32BASIC
Old-style comma joins vs explicit JOIN syntax β which and why?
-- old style (avoid)
SELECT * FROM orders o, customers c WHERE o.cust_id = c.id;
-- modern
SELECT * FROM orders o JOIN customers c ON o.cust_id = c.id;
Same result β but the explicit form is strictly better: forget the WHERE in the old style and you get a silent Cartesian product (every order Γ every customer); forget the ON in the new style and you get an error. It also separates join conditions from filters, which matters once you use LEFT JOIN. There is no reason to write the comma form today.
SQL-B-33BASIC
When is a CROSS JOIN actually useful?
Deliberately generating all combinations:
-- every store Γ every date = the complete reporting skeleton
SELECT s.store_id, d.dt
FROM stores s CROSS JOIN calendar d
WHERE d.dt BETWEEN '2026-01-01' AND '2026-12-31';
Left-join actuals onto that skeleton and days with zero sales appear as zero instead of vanishing. Other uses: parameter grids for testing, size Γ colour product variants. Accidental cross joins are a bug; intentional ones are a reporting technique.
SQL-B-34BASIC
ON vs USING vs NATURAL JOIN.
JOIN dept d ON e.dept_id = d.dept_id -- explicit, any condition
JOIN dept d USING (dept_id) -- shorthand when names match
NATURAL JOIN dept -- joins on ALL same-named columns
USING is fine shorthand. NATURAL JOIN is a trap to name and avoid: it silently joins on every column with a matching name β add a harmless created_at to both tables later and the join quietly breaks. Explicit is durable.
SQL-B-35BASIC
What are the rules for UNION-ing two queries?
Same number of columns in each SELECT.
Corresponding columns must be type-compatible (INT with INT, or castable).
Column names come from the first SELECT.
ORDER BY applies to the combined result and goes at the end.
SELECT id, name, 'customer' AS src FROM customers
UNION ALL
SELECT id, name, 'supplier' FROM suppliers
ORDER BY name;
That literal src column is the classic pattern for tagging which table each row came from.
SQL-B-36BASIC
What is an index, in plain terms?
A separate, sorted lookup structure that lets the database find rows without scanning the whole table β like a book's index: instead of reading 500 pages to find "Snowflake", you check the index and jump to page 214.
The trade everyone must be able to state: indexes make reads faster and writes slower β every INSERT/UPDATE/DELETE must also maintain each index, and each index consumes storage. So you index the columns you search and join on, not everything.
Follow-ups
Does an index on (a, b) help a query filtering only b? Generally no β composite indexes serve leftmost-prefix lookups. (Deep dive lives in the Advanced section.)
SQL-B-37BASIC
Explain normalization: 1NF, 2NF, 3NF β simply.
1NF β atomic values, no lists in a cell. A phones column holding "555-1234, 555-9999" violates it; split into rows or a child table.
2NF β 1NF, plus no attribute depending on part of a composite key. In order_items(order_id, product_id, product_nameβ¦), product_name depends only on product_id β move it to products.
3NF β 2NF, plus no attribute depending on another non-key attribute. employees(β¦, dept_id, dept_name) β dept_name depends on dept_id, not on the employee β move it to departments.
The one-line purpose: store each fact once, so it can never disagree with itself.
SQL-B-38BASIC
Then why do data warehouses denormalize?
Because the workload flips. OLTP systems write constantly and must avoid update anomalies β normalize. Warehouses are written once by a controlled pipeline and read thousands of times by humans β duplicate the data to eliminate joins, and manage the redundancy in the pipeline instead of the schema.
It's the same trade-off answered twice for two different workloads β framing it that way, rather than "normalization good/bad", is what earns the point.
SQL-B-39BASIC
Transaction basics: BEGIN, COMMIT, ROLLBACK β and ACID in one line each.
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT; -- or ROLLBACK; to undo both
Atomic β all statements succeed or none do.
Consistent β constraints hold before and after.
Isolated β concurrent transactions don't see each other's half-done work.
Durable β once committed, it survives a crash.
The transfer example above is the answer to "why transactions": without atomicity, a crash between the two updates loses money.
SQL-B-40BASIC
Database vs schema vs table β the object hierarchy.
Schemas exist for organisation and permissioning β grant a team access to one schema, not table-by-table. Note the vocabulary clash: MySQL uses "database" and "schema" interchangeably, while Postgres/Snowflake/SQL Server keep them as distinct levels.
SQL-B-41BASIC
COALESCE vs IFNULL vs NULLIF β don't mix them up.
COALESCE(a, b, c, 0) -- first non-null of the list (standard, use this)
IFNULL(a, 0) -- MySQL two-argument version
NVL(a, 0) -- Oracle's version
NULLIF(a, b) -- returns NULL when a = b (the reverse direction!)
NULLIF is the odd one out β it creates NULL. Its killer use: amount / NULLIF(qty, 0) turns divide-by-zero into NULL instead of an error.
SQL-B-42BASIC
CAST and CONVERT β explicit type conversion.
CAST('123' AS INT)
CAST(amount AS DECIMAL(10,2))
'2026-07-31'::date -- Postgres/Snowflake shorthand
TRY_CAST('abc' AS INT) -- NULL instead of error (SQL Server/Snowflake)
Two rules: prefer explicit casts over relying on implicit conversion (implicit casts on a filtered column can disable index/partition use), and in pipelines prefer TRY_CAST so one malformed value nulls out instead of failing the whole load β then count the NULLs as a data-quality check.
SQL-B-43BASIC
LIMIT vs TOP vs FETCH FIRST β the dialect map.
SELECT * FROM t ORDER BY x LIMIT 10; -- MySQL/Postgres/Snowflake
SELECT TOP 10 * FROM t ORDER BY x; -- SQL Server
SELECT * FROM t ORDER BY x FETCH FIRST 10 ROWS ONLY; -- standard/Oracle 12c+
Same idea, three spellings. The part that matters more than syntax: a row limiter without ORDER BY returns an arbitrary 10 rows β and a different 10 tomorrow.
SQL-B-44BASIC
Find duplicate values with GROUP BY β¦ HAVING.
SELECT email, COUNT(*) AS n
FROM users
GROUP BY email
HAVING COUNT(*) > 1
ORDER BY n DESC;
The single most useful data-quality query in existence. Extend it to multi-column duplicates by grouping the full business key β and if this ever returns rows on a column you believed unique, you've found a real defect, not a curiosity.
SQL-B-45BASIC
Delete duplicates keeping one copy β the classic ways.
-- 1) keep the lowest id (self-join delete)
DELETE FROM users u
WHERE u.id NOT IN (SELECT MIN(id) FROM users GROUP BY email);
-- 2) window-function way (engines supporting DELETE from CTE)
WITH ranked AS (
SELECT id, ROW_NUMBER() OVER (PARTITION BY email ORDER BY id) rn
FROM users)
DELETE FROM users WHERE id IN (SELECT id FROM ranked WHERE rn > 1);
In analytical warehouses the safer pattern is create-clean-and-swap: build a deduplicated copy, verify counts, then swap tables β reversible until the final step.
SQL-B-46BASIC
Second-highest salary without window functions.
-- max below the max
SELECT MAX(salary) FROM employees
WHERE salary < (SELECT MAX(salary) FROM employees);
-- or: OFFSET
SELECT DISTINCT salary FROM employees
ORDER BY salary DESC LIMIT 1 OFFSET 1;
Asked constantly because it tests thinking, not memory. Know both, plus the window version (in Intermediate) β and mention the edge case: with only one distinct salary, the first form returns NULL, which is usually the right answer.
SQL-B-47BASIC
Three ways to copy a table.
CREATE TABLE emp_backup AS SELECT * FROM emp; -- structure + data (CTAS)
CREATE TABLE emp_empty AS SELECT * FROM emp WHERE 1=0; -- structure only
INSERT INTO existing_tbl SELECT * FROM emp; -- data into an existing table
Know the limitation: CTAS typically copies columns and types but not indexes, constraints or defaults. A "backup" made this way silently lacks the primary key. (SQL Server's spelling is SELECT * INTO emp_backup FROM emp.)
SQL-B-48BASIC
Temporary table vs CTE vs view β when do you reach for each?
Lives for
Best for
CTE
one query
readability; step-wise logic in a single statement
Temp table
the session
reusing an expensive intermediate across several statements; can be indexed
View
permanent
a shared, named definition many queries/users rely on
Rule of thumb: start with a CTE; graduate to a temp table when you reuse the result or it's expensive; promote to a view when other people need it.
SQL-B-49BASIC
What's the difference between WHERE join filters on INNER vs LEFT JOIN?
The most practically important LEFT JOIN fact: a WHERE condition on the right table turns a LEFT JOIN back into an INNER JOIN.
-- WRONG: unmatched customers vanish (their o.status is NULL, NULL<>'x' fails)
SELECT c.*, o.total FROM customers c
LEFT JOIN orders o ON o.cust_id = c.id
WHERE o.status = 'complete';
-- RIGHT: keep the condition in the ON clause
SELECT c.*, o.total FROM customers c
LEFT JOIN orders o ON o.cust_id = c.id AND o.status = 'complete';
ON decides what matches; WHERE decides which result rows survive. Interviewers use this exact bug as a screening question.
SQL-B-50BASIC
What is a wildcard search's cost, and how do you search text efficiently?
Recap of the pieces you now know, assembled: LIKE 'abc%' can use an index (it's a range scan on the prefix); LIKE '%abc' or '%abc%' cannot β full scan. Options when you genuinely need contains-search at scale:
A full-text index (MySQL FULLTEXT, Postgres tsvector/GIN, SQL Server FTS).
A reversed column for suffix searches (LIKE 'cba%' on the reversed value).
In warehouses: Snowflake's Search Optimization Service for point lookups.
Closing the Basic level with the theme that runs through everything above: the engine can only be fast when your predicate lets it skip data.
SQL-B-51BASIC
What is a cursor β and why do experienced people avoid them?
A cursor iterates over a query's rows one at a time so you can process each individually β like a for-loop over a result set. Declare, open, fetch in a loop, close.
The catch: SQL engines are optimised for set-based operations. A cursor updating 100k rows one-by-one can be hundreds of times slower than one UPDATE statement doing the same thing. The interview answer that lands: "I know how cursors work, and I treat them as a last resort β almost every cursor can be rewritten as a set-based statement or a window function."
SQL-B-52BASIC
What is SQL injection, and how do you prevent it?
An attack where malicious SQL is smuggled in through user input. If code builds a query by gluing strings, input like ' OR '1'='1 changes the query's meaning β the classic login bypass; '; DROP TABLE users;-- is the destructive version.
Prevention, in order of importance:
Parameterised queries / prepared statements β the input is bound as data, never parsed as SQL. This alone defeats injection.
Least-privilege accounts β the app's DB user shouldn't be able to DROP anything.
Input validation β defence in depth, not the primary defence.
-- never: "SELECT * FROM users WHERE name = '" + input + "'"
-- always: SELECT * FROM users WHERE name = ? -- input bound as parameter
SQL-B-53BASIC
Fetch alternate records from a table.
-- odd ids
SELECT * FROM employees WHERE id % 2 = 1; -- MySQL/Postgres (MOD(id,2) in Oracle)
-- even ids
SELECT * FROM employees WHERE id % 2 = 0;
The id-based version assumes ids are dense. The version that always works β alternate by position, not id:
SELECT * FROM (
SELECT e.*, ROW_NUMBER() OVER (ORDER BY id) AS rn FROM employees e
) t WHERE rn % 2 = 1;
SQL-B-54BASIC
UNION vs JOIN β they combine tables in completely different directions.
The cleanest framing: UNION stacks rows vertically; JOIN attaches columns horizontally.
UNION β two result sets with the same columns, appended: January orders + February orders β one longer table.
JOIN β two tables related by a key, matched: orders + customers β wider rows with data from both.
Choose by the question: "more of the same shape" β UNION; "enrich rows with related data" β JOIN.
SQL-B-55BASIC
LEFT OUTER JOIN vs RIGHT OUTER JOIN.
Mirror images: LEFT keeps every row of the left table (NULL-padding unmatched right side); RIGHT keeps every row of the right table. Every RIGHT JOIN can be rewritten as a LEFT JOIN by swapping table order:
SELECT * FROM a RIGHT JOIN b ON a.id = b.aid
-- identical to
SELECT * FROM b LEFT JOIN a ON a.id = b.aid
Convention worth stating: most teams standardise on LEFT JOIN only β the "keep everything" table always goes first, and queries stay readable. RIGHT JOIN in review usually gets rewritten.
SQL-B-56BASIC
What is data integrity? Name its three kinds.
Entity integrity β every row is uniquely identifiable: primary keys, no duplicate rows.
Referential integrity β every foreign key points at a real parent row: no orphaned orders for deleted customers.
Domain integrity β every value is legal for its column: right type, right range (CHECK (salary > 0), NOT NULL, DEFAULT).
Tie it together in one sentence: constraints are how the schema enforces integrity automatically, so correctness doesn't depend on every application remembering the rules.
SQL-B-57BASIC
What is data warehousing, and how is a warehouse different from the application database?
A data warehouse is a central repository that consolidates data from many source systems for analysis and reporting. The contrast that structures the answer:
OLTP (app DB)
Warehouse (OLAP)
Workload
many tiny reads/writes
few huge analytical reads
Design
normalized
dimensional / denormalized
Data
current state
history, from many sources
Users
the application
analysts & dashboards
Snowflake, BigQuery and Redshift are warehouses; the full topic has its own course in the Engineering track.
SQL-B-58BASIC
The everyday utility functions: current date, month extraction, concatenation.
SELECT CURRENT_DATE; -- today
SELECT MONTH(order_date) FROM orders; -- MySQL; EXTRACT(MONTH FROM β¦) standard
SELECT CONCAT(first_name, ' ', last_name) AS full_name FROM employees;
Small but asked constantly. Two notes that add polish: standard SQL's portable forms are EXTRACT(MONTH FROM d) and the || concat operator; and in strict engines CONCAT with a NULL argument returns NULL β wrap with COALESCE(middle_name,'') when fields are optional.
SQL-B-59BASIC
What is a stored procedure?
A precompiled group of SQL statements saved in the database and run with a single call.
Accepts parameters and can contain logic (conditions, loops).
Encapsulates repetitive or complex tasks in one place.
Gives applications one safe entry point instead of raw table access.
SQL-B-60BASIC
What is a trigger?
A set of instructions that runs automatically in response to a table event.
Fires on INSERT, UPDATE or DELETE, before or after the change.
Common use: audit logging that no application can forget to write.
NEW holds the inserted row; OLD holds prior values.
SQL-B-61BASIC
What is a cursor?
A database object that iterates over query results one row at a time.
Allows row-by-row processing when set-based SQL isn't enough.
Slow versus set-based operations β use as a last resort.
Most cursors can be rewritten as a single statement or a window function.
SQL-B-62BASIC
What is a temporary table?
A table that exists only for a session or transaction, then is dropped automatically.
Stages intermediate results you don't need to keep.
Can carry indexes and statistics (unlike a table variable).
Lives in temporary space such as tempdb.
SQL-B-63BASIC
What is the COALESCE function?
Returns the first non-NULL value from a list of expressions.
Substitutes a default for missing values.
Example: COALESCE(phone, mobile, 'N/A').
Standard SQL β works across all major databases.
SQL-B-64BASIC
What is a correlated subquery?
A subquery that references the outer query, so it depends on each outer row.
Runs conceptually once per outer row.
Example: employees earning above their own department's average.
Often rewritable as a JOIN or window function for speed.
SQL-B-65BASIC
What is a self join?
A join where a table is joined to itself using two aliases.
Compares rows within the same table.
Classic use: employee β manager in one employees table.
Written by aliasing the table twice (e.g. e1, e2).
SQL-B-66BASIC
What is the difference between CHAR and VARCHAR?
Two string types differing in how they use space.
CHAR(n) β fixed length; pads short values with spaces.
VARCHAR(n) β variable length; stores only characters used.
CHAR for fixed-width codes, VARCHAR for names and free text.
SQL-B-67BASIC
What is the difference between UNION and JOIN?
They combine tables in opposite directions.
UNION β stacks rows vertically (same columns).
JOIN β attaches columns horizontally (matched by key).
"More of the same" β UNION; "enrich rows" β JOIN.
SQL-B-68BASIC
What is the MERGE (UPSERT) statement?
Synchronises a target table from a source in one statement.
Updates rows that match, inserts rows that are new.
Optionally deletes rows no longer present in the source.
The source must be unique on the join key, or it errors.
SQL-B-69BASIC
What is the difference between DROP, DELETE and TRUNCATE β quick recap?
All remove data, at different levels.
DELETE β removes selected rows; rollback-able; keeps the table.
TRUNCATE β removes all rows fast; keeps the table structure.
DROP β removes the entire table, structure included.
SQL-B-70BASIC
What is an alias in SQL?
A temporary, readable name given to a column or table in a query.
Column alias: SELECT price AS unit_price.
Table alias: FROM employees e β shortens references.
Exists only for the duration of the query.
SQL-B-71BASIC
What are the different types of SQL commands (DDL, DML, DCL, TCL)?
SQL commands fall into four families.
DDL β structure: CREATE, ALTER, DROP.
DML β data: SELECT, INSERT, UPDATE, DELETE.
DCL β permissions: GRANT, REVOKE.
TCL β transactions: COMMIT, ROLLBACK.
SQL-B-72BASIC
What is the difference between an aggregate and a scalar function?
Both are built-in functions, but operate on different inputs.
Aggregate β works over many rows, returns one value: SUM, AVG, COUNT.
Scalar β works on a single value per row: UPPER, ROUND, LEN.
Aggregates usually pair with GROUP BY; scalars don't.
SQL-B-73BASIC
What is a composite key?
A primary key made of two or more columns whose combination is unique.
No single column is unique on its own.
Common in junction tables (e.g. order_id + product_id).
Identifies exactly one row by the combination.
SQL-B-74BASIC
What is referential integrity?
A rule ensuring relationships between tables stay valid.
A foreign key must point to an existing primary key value.
Prevents "orphan" rows (e.g. an order for a deleted customer).
Enforced automatically by foreign key constraints.
SQL-B-75BASIC
What is the difference between IN and EXISTS?
Both test membership, but work differently.
IN β compares a value against a list or subquery result.
EXISTS β checks whether a subquery returns any row; stops at the first match.
EXISTS is usually safer with NULLs than NOT IN.
SQL-B-76BASIC
What is the difference between DELETE and DROP?
They operate at different levels.
DELETE β removes rows; the table and its structure remain.
DROP β removes the whole table, including its structure.
DELETE can be rolled back; DROP generally cannot.
SQL-B-77BASIC
What is a candidate key?
Any column (or set) that could serve as the primary key.
Uniquely identifies rows and contains no NULLs.
A table can have several candidate keys.
One is chosen as the primary key; the rest are "alternate keys".
SQL-B-78BASIC
What is the ORDER BY clause?
Sorts the result set by one or more columns.
ASC (default) ascending, DESC descending.
Can sort by multiple columns and by a column alias.
Runs last, so it can use aliases defined in SELECT.
SQL-B-79BASIC
What is the difference between HAVING and WHERE (quick recap)?
They filter at different stages.
WHERE filters rows before grouping.
HAVING filters groups after aggregation.
Only HAVING can use aggregates like COUNT().
SQL-B-80BASIC
What is the GROUP BY clause used for?
Groups rows sharing a value so aggregates can be applied per group.
One output row per distinct combination of grouped columns.
Every selected column must be grouped or aggregated.
SQL-B-81BASIC
What is a wildcard in SQL?
Special characters used with LIKE for pattern matching.
% β any number of characters.
_ β exactly one character.
SQL-B-82BASIC
What is the BETWEEN operator?
Selects values within a range, inclusive of both ends.
SELECT * FROM products WHERE price BETWEEN 10 AND 50;
SQL-B-83BASIC
What is the IN operator?
Tests whether a value matches any value in a list or subquery.
SELECT * FROM orders WHERE status IN ('open','paid');
SQL-B-84BASIC
What is the difference between NULL and 0 or empty string?
NULL β unknown/absent value.
0 β a known numeric value.
'' β a known empty text value.
SQL-B-85BASIC
What are the main data types in SQL?
Numeric: INT, DECIMAL, FLOAT.
String: CHAR, VARCHAR, TEXT.
Date/time: DATE, TIMESTAMP.
Boolean where supported.
SQL-B-86BASIC
What is the LIMIT clause?
Restricts the number of rows returned.
SELECT * FROM orders ORDER BY order_date DESC LIMIT 5;
SQL Server uses TOP; Oracle uses FETCH FIRST.
SQL-B-87BASIC
What is the difference between UPPER and LOWER functions?
Case-conversion scalar functions.
UPPER(s) β converts to uppercase.
LOWER(s) β converts to lowercase.
Used to normalise text before comparing.
SQL-B-88BASIC
What does the DEFAULT constraint do?
Supplies a value automatically when none is provided on insert.
status VARCHAR(10) DEFAULT 'active'
SQL-B-89BASIC
What is the CHECK constraint?
Ensures values in a column satisfy a condition.
salary DECIMAL CHECK (salary > 0)
SQL-B-90BASIC
What is the difference between an inner query and an outer query?
Inner query β the subquery that runs to supply a value/set.
Outer query β the main query that uses the subquery's result.
Intermediate 50 in full
Applied SQL β the questions that separate "can query" from "can build".
SQL-I-01INTERMEDIATE
Find the second-highest salary per department.
Rank within each department and filter to rank 2. DENSE_RANK so ties share the position:
SELECT department, salary
FROM (
SELECT department, salary,
DENSE_RANK() OVER (PARTITION BY department
ORDER BY salary DESC) AS rk
FROM employees
) t
WHERE rk = 2;
Engines with QUALIFY (Snowflake, BigQuery) let you skip the subquery entirely.
Follow-ups
ROW_NUMBER vs RANK vs DENSE_RANK? Unique sequence / gaps after ties / no gaps after ties.
SQL-I-02INTERMEDIATE
What are window functions and how do they differ from GROUP BY?
A window function computes across a set of rows related to the current row without collapsing them. GROUP BY returns one row per group; a window keeps every row and adds the computed value alongside.
SELECT name, dept, salary,
AVG(salary) OVER (PARTITION BY dept) AS dept_avg,
salary - AVG(salary) OVER (PARTITION BY dept) AS diff
FROM employees;
Follow-ups
PARTITION BY vs ORDER BY inside OVER? Partition defines the group; order defines sequence and the running frame.
SQL-I-03INTERMEDIATE
What is a CTE, and when would you use a recursive one?
A Common Table Expression is a named, readable subquery defined with WITH. It improves readability and lets you reference the same intermediate result more than once.
A recursive CTE references itself to walk hierarchies or generate sequences β an org chart, a bill-of-materials, or a date spine:
WITH RECURSIVE chain AS (
SELECT id, manager_id, 1 AS lvl FROM emp WHERE manager_id IS NULL
UNION ALL
SELECT e.id, e.manager_id, c.lvl + 1
FROM emp e JOIN chain c ON e.manager_id = c.id
)
SELECT * FROM chain;
Follow-ups
CTE vs subquery β performance? Usually equivalent; some engines materialise CTEs, so test on large data.
SQL-I-04INTERMEDIATE
Calculate a running total and a 7-day moving average.
SELECT dt, amount,
SUM(amount) OVER (ORDER BY dt
ROWS UNBOUNDED PRECEDING) AS running_total,
AVG(amount) OVER (ORDER BY dt
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS ma7
FROM daily_sales;
The frame clause (ROWS BETWEEN β¦) is the key β it defines exactly which rows the aggregate sees.
Trap With ORDER BY and no explicit frame, the default is RANGE UNBOUNDED PRECEDING, which ties on equal values and can silently give wrong running totals. State the frame.
SQL-I-05INTERMEDIATE
How do you pivot rows into columns?
Conditional aggregation is the portable way β it works everywhere and is clearer than dialect-specific PIVOT:
SELECT product,
SUM(CASE WHEN quarter='Q1' THEN amount END) AS q1,
SUM(CASE WHEN quarter='Q2' THEN amount END) AS q2,
SUM(CASE WHEN quarter='Q3' THEN amount END) AS q3
FROM sales
GROUP BY product;
Follow-ups
Unpivot? Use UNPIVOT or a UNION ALL of one SELECT per column.
SQL-I-06INTERMEDIATE
EXISTS vs IN vs JOIN β when do you use each?
IN β clean for a small, static list; watch the NULL trap with NOT IN.
EXISTS β best for correlated existence checks; short-circuits on first match and is NULL-safe.
JOIN β when you actually need columns from the other table, not just a membership test.
On modern optimisers IN and EXISTS often plan identically; the meaningful, reliable difference is NOT IN vs NOT EXISTS with NULLs.
SQL-I-07INTERMEDIATE
Remove duplicate rows, keeping the most recent per key.
WITH ranked AS (
SELECT *,
ROW_NUMBER() OVER (PARTITION BY customer_id
ORDER BY updated_at DESC) AS rn
FROM customers
)
SELECT * FROM ranked WHERE rn = 1;
You need a deterministic ordering column (timestamp or sequence). If none exists, that missing column is the real defect to raise.
SQL-I-08INTERMEDIATE
LEAD and LAG β what are they for?
They read a value from a following/preceding row without a self-join. Classic use: change over time.
SELECT month, revenue,
LAG(revenue) OVER (ORDER BY month) AS prev,
revenue - LAG(revenue) OVER (ORDER BY month) AS delta
FROM monthly_revenue;
Third argument is a default for the edge row: LAG(revenue, 1, 0).
SQL-I-09INTERMEDIATE
What does QUALIFY do?
It filters on window-function results directly β no wrapping subquery (Snowflake, BigQuery, Teradata, DuckDB):
SELECT * FROM events
QUALIFY ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY ts DESC) = 1;
Logically it runs after SELECT's windows are computed β think of it as HAVING for windows. If the dialect lacks it, you wrap in a subquery and filter there.
SQL-I-10INTERMEDIATE
Find employees who earn more than their manager.
The canonical self-join:
SELECT e.name AS employee, e.salary, m.name AS manager, m.salary AS mgr_salary
FROM employees e
JOIN employees m ON e.manager_id = m.emp_id
WHERE e.salary > m.salary;
Alias the table twice and treat them as two tables. The same shape answers "same city pairs", "events within 5 minutes of each other", etc.
SQL-I-11INTERMEDIATE
Generate a date spine (every day in a range) and why you need one.
Aggregating events by day silently skips days with no events β a chart with missing days lies. Fix: generate all dates, left-join the data onto them.
-- Snowflake/Postgres style
WITH days AS (
SELECT DATEADD(day, seq4(), '2026-01-01')::date AS dt
FROM TABLE(GENERATOR(ROWCOUNT => 365))
)
SELECT d.dt, COALESCE(SUM(s.amount), 0) AS revenue
FROM days d LEFT JOIN sales s ON s.sale_date = d.dt
GROUP BY d.dt ORDER BY d.dt;
a = b is unknown when either side is NULL β so change-detection queries miss NULL transitions. The standard tool:
WHERE s.amount IS DISTINCT FROM w.amount -- true when they differ, NULLs handled
Dialects without it: NOT (a = b OR (a IS NULL AND b IS NULL)), or MySQL's NOT (a <=> b). Essential for reconciliation and SCD change detection.
SQL-I-13INTERMEDIATE
Write an anti-join three ways. Which do you prefer?
-- 1) NOT EXISTS (preferred: NULL-safe, optimises well)
SELECT c.* FROM customers c
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.cust_id = c.id);
-- 2) LEFT JOIN β¦ IS NULL
SELECT c.* FROM customers c
LEFT JOIN orders o ON o.cust_id = c.id
WHERE o.cust_id IS NULL;
-- 3) NOT IN (dangerous if the subquery can return NULL)
Say the preference and the reason: NOT EXISTS, because NOT IN returns zero rows if the subquery yields a single NULL.
SQL-I-14INTERMEDIATE
What do GROUPING SETS, ROLLUP and CUBE do?
They compute several GROUP BY levels in one pass:
ROLLUP(region, product) β (region, product), (region), grand total β a hierarchy.
CUBE(region, product) β every combination of the two.
GROUPING SETS ((region),(product),()) β exactly the sets you list.
Subtotal rows show NULL in the rolled-up column; use GROUPING(col) to distinguish "subtotal NULL" from a real NULL value.
SQL-I-15INTERMEDIATE
Find departments where the average salary is above the company average.
SELECT dept, AVG(salary) AS dept_avg
FROM employees
GROUP BY dept
HAVING AVG(salary) > (SELECT AVG(salary) FROM employees);
A tidy demonstration that HAVING can compare a group aggregate to a scalar subquery. The subquery is non-correlated β it runs once.
SQL-I-16INTERMEDIATE
What is a stored procedure, and when would you use one over a regular query?
A named, precompiled batch of SQL stored in the database β it accepts parameters, contains logic (IF/loops), and is executed with one call.
CREATE PROCEDURE give_raise(IN p_dept VARCHAR(50), IN p_pct DECIMAL(4,2))
BEGIN
UPDATE employees
SET salary = salary * (1 + p_pct/100)
WHERE department = p_dept;
END;
CALL give_raise('Sales', 10);
Reach for one when: the same multi-step logic runs repeatedly (month-end processing), several statements must succeed together with error handling, or you want apps to get one safe entry point instead of raw table access. In modern warehouse stacks much of this moved to dbt/orchestration β knowing both worlds is a plus.
SQL-I-17INTERMEDIATE
What is a trigger? Write an AFTER INSERT audit trigger.
A trigger fires automatically in response to table events (INSERT/UPDATE/DELETE), BEFORE or AFTER the change. Canonical use: audit logging that no application can forget to do.
CREATE TRIGGER trg_emp_audit
AFTER INSERT ON employees
FOR EACH ROW
INSERT INTO audit_log(action, table_name, record_id, changed_at)
VALUES ('INSERT', 'employees', NEW.id, NOW());
NEW holds the inserted row (OLD holds prior values in UPDATE/DELETE triggers). The balanced view interviewers want: triggers guarantee side-effects but hide logic β overuse makes systems hard to debug ("why did that row change?"), so keep them for integrity/audit, not business workflow.
SQL-I-18INTERMEDIATE
How do you handle errors inside a stored procedure?
-- SQL Server pattern
BEGIN TRY
BEGIN TRANSACTION;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
END TRY
BEGIN CATCH
IF @@TRANCOUNT > 0 ROLLBACK;
INSERT INTO error_log(msg, at) VALUES (ERROR_MESSAGE(), GETDATE());
THROW; -- re-raise so callers know it failed
END CATCH;
The three moves: rollback the transaction, log the error, re-raise it. Swallowing errors silently is the anti-pattern β the caller believes the transfer happened. (MySQL's equivalent is DECLARE β¦ HANDLER; the shape is the same.)
SQL-I-19INTERMEDIATE
Function vs stored procedure β the differences that matter.
Function
Stored procedure
Returns
exactly one value (or a table)
zero+ results, OUT params
Usable in SELECT?
yes β SELECT fn(col)β¦
no β invoked with CALL/EXEC
Side effects
generally must not modify data
freely modifies data
Transactions
no control
can BEGIN/COMMIT/ROLLBACK
One-liner: functions compute, procedures do. A tax calculation is a function; month-end processing is a procedure.
SQL-I-20INTERMEDIATE
When would a recursive stored procedure be useful β and what's usually better?
A procedure that calls itself, classically for hierarchies: walk an employee's reporting chain, explode a bill-of-materials. Each call processes one level and recurses into children.
The stronger answer: in modern SQL a recursive CTE (see SQL-A-08) solves the same problems declaratively, in one statement the optimiser can reason about β procedures add per-call overhead and recursion-depth limits. Mention the procedure approach exists, then say you'd reach for the CTE; that ordering signals experience.
SQL-I-21INTERMEDIATE
Temp table vs table variable (SQL Server).
#temp table
@table variable
Lives in
tempdb
tempdb too (memory-optimised myth!)
Statistics
yes β good plans on big data
no β optimiser assumes ~1 row
Indexes
full support
limited (PK/inline only)
Transactions
rolled back
survives rollback
Scope
session
batch/procedure
Guidance: table variables for tiny row counts in short procedures; temp tables when volume is non-trivial or the optimiser needs statistics. The "assumes one row" behaviour is the classic cause of a procedure that's fast in testing and slow in production.
SQL-I-22INTERMEDIATE
Describe a scenario for MERGE (upsert) and write the shape.
Synchronising a target with a source: rows that exist get updated, new rows get inserted, vanished rows optionally deleted β one atomic statement instead of three.
MERGE INTO customers t
USING staged_customers s ON t.customer_id = s.customer_id
WHEN MATCHED THEN UPDATE SET name = s.name, email = s.email
WHEN NOT MATCHED THEN INSERT (customer_id, name, email)
VALUES (s.customer_id, s.name, s.email);
Guardrail to mention: the source must be unique on the join key β duplicate source keys make MERGE error (by design: it refuses to update one row twice). The advanced SCD Type-2 variant is SQL-A-03.
SQL-I-23INTERMEDIATE
Get the last record from a table without ORDER BY β¦ LIMIT.
-- match the maximum key
SELECT * FROM orders
WHERE order_id = (SELECT MAX(order_id) FROM orders);
-- or the latest timestamp
SELECT * FROM orders
WHERE created_at = (SELECT MAX(created_at) FROM orders);
The real lesson inside this classic: "last" is undefined without a column that encodes order β tables have no inherent row order. Whichever column you MAX over is your definition of "last"; say that out loud and the interviewer hears precision.
SQL-I-24INTERMEDIATE
Rewrite a cursor as a set-based statement.
The transformation interviewers love. Cursor version: loop employees, compute each one's department average, update a column. Set-based version:
UPDATE employees e
SET dept_avg = t.avg_sal
FROM (SELECT department, AVG(salary) AS avg_sal
FROM employees GROUP BY department) t
WHERE e.department = t.department;
One statement, one pass, optimiser-friendly, and it can't be interrupted half-way through the loop. The general recipe: whatever the loop computes per row, express as a joined aggregate/window; whatever the loop does per row, express as one UPDATE/INSERT/DELETE against that result.
SQL-I-25INTERMEDIATE
What does the MERGE (UPSERT) statement do?
Definition:MERGE synchronises a target table from a source in one atomic statement β insert, update and optionally delete together.
MERGE INTO customers t
USING staged s ON t.id = s.id
WHEN MATCHED THEN UPDATE SET name = s.name, email = s.email
WHEN NOT MATCHED THEN INSERT (id, name, email) VALUES (s.id, s.name, s.email);
Replaces a separate INSERT + UPDATE + DELETE sequence.
The source must be unique on the join key, or MERGE errors by design.
Core to idempotent data loads.
SQL-I-26INTERMEDIATE
ROW_NUMBER vs RANK vs DENSE_RANK.
Definition: three ranking window functions that differ only in how they treat ties.
Function
Ties
Example (90,90,80)
ROW_NUMBER
broken arbitrarily
1, 2, 3
RANK
share rank, then gap
1, 1, 3
DENSE_RANK
share rank, no gap
1, 1, 2
Choose: ROW_NUMBER for "exactly one per group", DENSE_RANK for "top-N including ties".
SQL-I-27INTERMEDIATE
What is query caching?
Definition: storing a query's result so an identical re-run returns instantly without re-executing.
Ideal for repeated, unchanged dashboard queries β near-zero cost.
Must be invalidated when the underlying data changes, or it serves stale results.
When benchmarking, disable it β otherwise you measure the cache, not the query.
SQL-I-28INTERMEDIATE
ROW_NUMBER vs RANK in practice β pick one for "one row per group".
Scenario: you want the single most recent order per customer.
ROW_NUMBER guarantees exactly one row even when two orders share a timestamp.
RANK/DENSE_RANK would return both tied rows β wrong for "pick one".
Rule: "one per group" β ROW_NUMBER with a deterministic tie-breaker in the ORDER BY.
SQL-I-29INTERMEDIATE
What is a materialized view?
Definition: a view whose results are physically stored, unlike a normal view that recomputes each query.
Faster reads for expensive aggregations queried often.
Must be refreshed to reflect new data β a staleness trade-off.
Costs storage and refresh compute.
SQL-I-30INTERMEDIATE
How does CASE work inside aggregation?
Definition: a CASE inside an aggregate enables conditional counting/summing β a mini-pivot.
SELECT
SUM(CASE WHEN status='paid' THEN amount ELSE 0 END) AS paid,
COUNT(CASE WHEN status='open' THEN 1 END) AS open_cnt
FROM invoices;
The CASE decides which rows contribute to each aggregate β the basis of pivoting.
SQL-I-31INTERMEDIATE
What is the difference between a CTE and a subquery?
Definition: both are nested queries; a CTE is named and declared up front with WITH.
A CTE can be referenced multiple times in the main query; a subquery is usually single-use.
CTEs improve readability for step-wise logic.
Performance is often equivalent β some engines materialise CTEs, so test on large data.
SQL-I-32INTERMEDIATE
What is a recursive CTE used for?
Definition: a CTE that references itself to walk hierarchies or generate sequences.
WITH RECURSIVE chain AS (
SELECT id, manager_id, 1 AS lvl FROM emp WHERE manager_id IS NULL
UNION ALL
SELECT e.id, e.manager_id, c.lvl+1
FROM emp e JOIN chain c ON e.manager_id = c.id
)
SELECT * FROM chain;
Uses an anchor member + a recursive member joined by UNION ALL.
Guard against cycles with a depth limit.
SQL-I-33INTERMEDIATE
What do LAG and LEAD do?
Definition: window functions that read a value from a previous (LAG) or next (LEAD) row without a self-join.
SELECT month, revenue,
revenue - LAG(revenue) OVER (ORDER BY month) AS delta
FROM monthly_revenue;
Classic use: period-over-period change.
Third argument sets a default for the edge row: LAG(x,1,0).
SQL-I-34INTERMEDIATE
What is the difference between WHERE and ON in a JOIN?
Definition:ON decides what matches; WHERE decides which result rows survive.
-- keeps unmatched customers:
LEFT JOIN orders o ON o.cust_id=c.id AND o.status='paid'
-- turns LEFT into INNER (drops unmatched):
LEFT JOIN orders o ON o.cust_id=c.id WHERE o.status='paid'
On a LEFT JOIN, a condition on the right table belongs in ON to preserve unmatched rows.
SQL-I-35INTERMEDIATE
What is the difference between NOT IN and NOT EXISTS?
Definition: both express "not present in the other set", but handle NULLs very differently.
NOT IN returns zero rows if the subquery yields a single NULL.
NOT EXISTS is NULL-safe and usually optimises as well or better.
Rule: prefer NOT EXISTS for anti-joins.
SQL-I-36INTERMEDIATE
What is a window function's frame clause?
Definition: the ROWS/RANGE BETWEEN β¦ part that defines exactly which rows the window aggregate sees.
SUM(x) OVER (ORDER BY dt ROWS BETWEEN 6 PRECEDING AND CURRENT ROW)
Trap With ORDER BY and no explicit frame, the default is RANGE UNBOUNDED PRECEDING, which ties on equal values and can silently give wrong running totals. State the frame.
SQL-I-37INTERMEDIATE
How do you pivot rows into columns?
Definition: turn categorical row values into columns β the portable way is conditional aggregation.
SELECT product,
SUM(CASE WHEN q='Q1' THEN amt END) AS q1,
SUM(CASE WHEN q='Q2' THEN amt END) AS q2
FROM sales GROUP BY product;
Works in every dialect β clearer and more portable than engine-specific PIVOT.
SQL-I-38INTERMEDIATE
UNION vs FULL OUTER JOIN β how do they differ?
Definition: both can bring two tables together, but in different directions.
UNION β stacks rows vertically; both queries need the same columns.
FULL OUTER JOIN β matches two tables on a key, keeping unmatched rows from both sides.
Appending similar data β UNION; combining related data by key β JOIN.
SQL-I-39INTERMEDIATE
What is the QUALIFY clause?
Definition: filters on window-function results directly, without a wrapping subquery β like HAVING for windows.
SELECT * FROM events
QUALIFY ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY ts DESC) = 1;
Available in Snowflake, BigQuery, Teradata, DuckDB.
Where unsupported, wrap in a subquery and filter on the alias.
SQL-I-40INTERMEDIATE
What are GROUPING SETS, ROLLUP and CUBE?
Definition: extensions that compute several GROUP BY levels in one pass.
ROLLUP(a,b) β (a,b), (a), grand total β a hierarchy of subtotals.
CUBE(a,b) β every combination of the two.
GROUPING SETS ((a),(b),()) β exactly the sets you list.
Subtotal rows show NULL in the rolled-up column; use GROUPING() to tell them from real NULLs.
SQL-I-41INTERMEDIATE
How do you find the top-N rows per group?
Pattern: rank within each partition, then filter.
SELECT * FROM (
SELECT *, ROW_NUMBER() OVER (PARTITION BY grp ORDER BY val DESC) rn
FROM t
) x WHERE rn <= 3;
Use DENSE_RANK instead of ROW_NUMBER if ties at the boundary should all be kept.
SQL-I-42INTERMEDIATE
Scalar vs correlated subquery β what's the performance difference?
Scalar (non-correlated) β returns one value, runs once, independent of the outer query.
Correlated β references the outer row, so it runs conceptually once per outer row.
Most correlated subqueries in a SELECT list should be rewritten as a JOIN or window function, which the engine runs in a single pass.
SQL-I-43INTERMEDIATE
What is an index, and when does it help vs hurt?
Definition: a sorted lookup structure that lets the engine find rows without scanning the whole table.
Helps WHERE, JOIN, ORDER BY on the indexed column.
Slows INSERT/UPDATE/DELETE β every index must be maintained.
Costs storage β index the columns you actually filter/join on, not all.
SQL-I-44INTERMEDIATE
What is a covering index?
Definition: a non-clustered index that contains every column a query needs, so the query is answered from the index alone.
CREATE INDEX ix ON orders(customer_id, order_date) INCLUDE (total);
Avoids the extra hop back to the base table β a big win for hot queries.
SQL-I-45INTERMEDIATE
How do you handle NULLs in aggregation?
Most aggregates (SUM, AVG, MIN, MAX) ignore NULLs.
COUNT(*) counts all rows; COUNT(col) skips NULLs.
Use AVG(COALESCE(col,0)) when NULL should count as zero.
TrapAVG(col) averages only non-null rows β different from SUM(col)/COUNT(*) when NULLs exist. Decide which the business means.
SQL-I-46INTERMEDIATE
INNER JOIN vs CROSS JOIN.
INNER JOIN β matches rows on a condition; returns only matches.
CROSS JOIN β Cartesian product; every left row Γ every right row, no condition.
Deliberate CROSS JOINs build grids/skeletons (all store Γ all date); accidental ones (a forgotten join condition) are a classic bug.
SQL-I-47INTERMEDIATE
How do you calculate a moving average?
Pattern: a windowed average over a sliding frame of rows.
SELECT dt, amount,
AVG(amount) OVER (ORDER BY dt
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS ma7
FROM daily_sales;
The frame clause defines the window β here the current row plus the six before it (a 7-day average).
SQL-I-48INTERMEDIATE
How does DELETE interact with a transaction rollback?
DELETE is logged, so within a transaction it can be undone by ROLLBACK.
Once COMMITted, the delete is permanent.
Wrapping risky deletes in a transaction lets you verify counts before committing.
This is why previewing and transactions are the safe-delete habit.
SQL-I-49INTERMEDIATE
What are the ACID properties?
Definition: the four guarantees that make transactions reliable.
Atomicity β all statements succeed or none do.
Consistency β constraints hold before and after.
Isolation β concurrent transactions don't see each other's half-done work.
Durability β once committed, it survives a crash.
A money transfer (two updates) is the canonical example: without atomicity a crash between them loses money.
SQL-I-50INTERMEDIATE
Why can't you filter an aggregate in WHERE?
Reason:WHERE runs before grouping, so aggregates don't exist yet at that stage.
Filter aggregates in HAVING, which runs after GROUP BY.
Use WHERE to filter raw rows first and shrink the groups.
SELECT dept, COUNT(*) FROM emp
WHERE active = true -- rows first
GROUP BY dept HAVING COUNT(*) > 5; -- groups after
SQL-I-51INTERMEDIATE
How do you remove duplicates keeping the latest row?
Pattern: rank per key by recency, keep rank 1.
WITH r AS (
SELECT *, ROW_NUMBER() OVER (PARTITION BY key_col
ORDER BY updated_at DESC) rn
FROM t)
SELECT * FROM r WHERE rn = 1;
You need a deterministic ordering column (timestamp or sequence); if none exists, that missing column is the real defect to raise.
SQL-I-52INTERMEDIATE
TRUNCATE vs DELETE inside a transaction.
DELETE β logged row-by-row; can be rolled back; slower.
TRUNCATE β deallocates pages; fast; in many engines cannot be rolled back.
Reach for TRUNCATE to empty a staging table fast; DELETE when you need a WHERE or rollback safety.
SQL-I-53INTERMEDIATE
What is a semi-join and an anti-join?
Semi-join β rows in A that have a match in B (via EXISTS/IN).
Anti-join β rows in A with no match in B (via NOT EXISTS).
Neither adds columns from B β they only test existence, so they never duplicate A's rows.
SQL-I-54INTERMEDIATE
How do you generate a date range (spine)?
Why: aggregating events by day silently skips days with no events β a chart with gaps lies.
-- Postgres
SELECT generate_series('2026-01-01'::date, '2026-12-31', '1 day') AS dt;
Left-join the data onto the spine so empty days appear as zero, not missing.
SQL-I-55INTERMEDIATE
What is the NTILE window function?
Definition: divides ordered rows into N roughly equal buckets.
NTILE(4) OVER (ORDER BY score) AS quartile
Used for quartiles, deciles and percentile bands β e.g. splitting customers into spend tiers.
SQL-I-56INTERMEDIATE
COUNT(*) vs COUNT(1) vs COUNT(col).
COUNT(*) and COUNT(1) β identical; count all rows including NULLs.
COUNT(col) β counts only rows where col is not NULL.
Modern optimisers treat * and 1 the same, so there's no performance reason to prefer one.
SQL-I-57INTERMEDIATE
Primary key vs unique key.
Primary key
Unique key
Per table
one
several
NULLs
none
usually one allowed
Role
row identity
enforce uniqueness
Both enforce uniqueness; only the primary key defines the row's identity.
SQL-I-58INTERMEDIATE
Find records in one table but not another.
-- anti-join (preferred)
SELECT a.* FROM a
WHERE NOT EXISTS (SELECT 1 FROM b WHERE b.id = a.id);
-- set operator
SELECT id FROM a EXCEPT SELECT id FROM b;
Use NOT EXISTS when you need full rows from A; EXCEPT when comparing whole result sets.
SQL-I-59INTERMEDIATE
What does EXCEPT / MINUS do?
Definition: returns rows from the first query that are not in the second.
EXCEPT β standard, Postgres, SQL Server.
MINUS β Oracle's spelling.
Both remove duplicates, like UNION.
SQL-I-60INTERMEDIATE
What is INTERSECT?
Definition: returns only rows present in both query results, with duplicates removed.
Conceptually an inner join on all columns β handy for "which ids appear in both sets".
SQL-I-61INTERMEDIATE
Update a column using values from another table.
UPDATE target t
SET price = s.new_price
FROM staging s
WHERE t.id = s.id;
Correctness caveat: if staging has two rows for one id, behaviour is engine-dependent (error or arbitrary winner). Guarantee the source is unique on the join key, or use MERGE.
SQL-I-62INTERMEDIATE
Temporary table vs CTE β when to use each.
CTE β scoped to one statement; a readability aid.
Temp table β persists for the session; can be reused across statements and indexed.
Rule of thumb: start with a CTE; switch to a temp table when the same expensive result is reused several times.
SQL-I-63INTERMEDIATE
FIRST_VALUE / LAST_VALUE window functions.
Definition: return the first/last value within an ordered window frame.
FIRST_VALUE(price) OVER (PARTITION BY product ORDER BY dt) AS first_price
TrapLAST_VALUE needs an explicit frame extended to the full partition (ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING), or it returns the current row.
SQL-I-64INTERMEDIATE
Percentage of total in one query.
SELECT category, SUM(amt) AS s,
ROUND(100.0*SUM(amt)/SUM(SUM(amt)) OVER (),1) AS pct
FROM sales GROUP BY category;
The window SUM(SUM(amt)) OVER () totals across all groups, giving each group's share in a single pass.
SQL-I-65INTERMEDIATE
DATEDIFF vs DATEADD.
DATEDIFF β the gap between two dates.
DATEADD β shifts a date by an interval.
Portable alternative is interval arithmetic: dt + INTERVAL '1 day', dt2 - dt1.
SQL-I-66INTERMEDIATE
Handle division by zero safely.
SELECT revenue / NULLIF(units, 0) AS price_per_unit FROM t;
NULLIF(x,0) turns a zero denominator into NULL, so the row yields NULL instead of raising an error.
SQL-I-67INTERMEDIATE
Implicit vs explicit join.
Implicit β comma syntax with the join condition in WHERE (avoid).
Explicit β JOIN β¦ ON (preferred).
Explicit separates join conditions from filters and turns a forgotten condition into an error rather than a silent Cartesian product.
SQL-I-68INTERMEDIATE
Count distinct values per group.
SELECT department, COUNT(DISTINCT job_title) AS roles
FROM employees GROUP BY department;
COUNT(DISTINCT β¦) counts unique non-null values within each group β e.g. how many distinct roles per department.
SQL-I-69INTERMEDIATE
COALESCE vs ISNULL vs IFNULL.
COALESCE(a,b,c,β¦) β standard, any number of arguments.
ISNULL(a,b) β SQL Server, two arguments.
IFNULL(a,b) β MySQL, two arguments.
Prefer COALESCE for portability and multiple fallbacks.
SQL-I-70INTERMEDIATE
Find the median in SQL.
SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY amount) AS median
FROM orders;
PERCENTILE_CONT interpolates between rows.
PERCENTILE_DISC returns an actual stored value.
The median resists skew from outliers that would drag the average.
SQL-I-71INTERMEDIATE
GROUP BY vs DISTINCT.
DISTINCT β removes duplicate rows.
GROUP BY β groups rows so aggregates can be applied.
For plain de-duplication they can be equivalent; only GROUP BY lets you add COUNT/SUM per group.
SQL-I-72INTERMEDIATE
Concatenate values from multiple rows into one string.
-- Postgres
SELECT dept, STRING_AGG(name, ', ') FROM employees GROUP BY dept;
-- MySQL
SELECT dept, GROUP_CONCAT(name SEPARATOR ', ') FROM employees GROUP BY dept;
An aggregate that collapses many row values into a single delimited string per group.
SQL-I-73INTERMEDIATE
Left semi join vs inner join.
Inner join β returns matched rows and columns from both tables; can duplicate left rows if B has many matches.
Semi join β returns only the left rows that have at least one match, no duplication, no B columns.
Semi join is expressed with EXISTS/IN β use it when you only need to test existence.
SQL-I-74INTERMEDIATE
Rank rows without a wrapping subquery.
Use QUALIFY where supported to filter directly on a window function.
SELECT * FROM sales
QUALIFY RANK() OVER (PARTITION BY region ORDER BY amt DESC) <= 3;
Cleaner than the subquery-and-filter pattern; falls back to a subquery on engines without QUALIFY.
Advanced 30 in full
Depth, performance and correctness under pressure.
SQL-A-01ADVANCED
A query joining two large tables is slow and returns more rows than expected. Diagnose it.
Read the execution plan first, not the SQL. Then in order: check partition pruning (bytes scanned vs table size), check the row count after the join β if it exceeds both inputs you have a fan-out from a non-unique join key, which is both a performance and a correctness bug β and check for spilling to disk.
The fix is almost never "add an index" on a columnar warehouse; it's filtering earlier, fixing the join grain, or clustering.
Trap "Add DISTINCT to remove the extra rows." That masks a fan-out instead of fixing it, and silently drops legitimately distinct rows the moment a column changes.
SQL-A-02ADVANCED
Solve the "gaps and islands" problem.
Identify consecutive runs (islands) of values β e.g. consecutive login days. The classic trick: subtract a ROW_NUMBER() from the sequence; within a run the difference is constant, so it becomes a group key.
SELECT user_id, MIN(dt) AS start_dt, MAX(dt) AS end_dt, COUNT(*) AS days
FROM (
SELECT user_id, dt,
dt - (ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY dt)) AS grp
FROM logins
) t
GROUP BY user_id, grp;
Follow-ups
Why does the subtraction work? Consecutive dates and consecutive row numbers advance in lockstep, so their difference is invariant within a run.
SQL-A-03ADVANCED
How do you implement a Slowly Changing Dimension (Type 2) in SQL?
Keep full history by adding a new row per change with validity columns, rather than overwriting. A MERGE closes the current row and inserts the new version:
MERGE INTO dim_customer d
USING staging s ON d.cust_id = s.cust_id AND d.is_current
WHEN MATCHED AND (d.segment <> s.segment)
THEN UPDATE SET d.valid_to = CURRENT_DATE, d.is_current = FALSE
WHEN NOT MATCHED
THEN INSERT (cust_id, segment, valid_from, valid_to, is_current)
VALUES (s.cust_id, s.segment, CURRENT_DATE, NULL, TRUE);
Facts join on the surrogate key captured at load time, which freezes the correct version.
SQL-A-04ADVANCED
What is the difference between a correlated and a non-correlated subquery, and why care?
A non-correlated subquery runs once, independently. A correlated subquery references the outer row, so naively it runs once per outer row β potentially catastrophic on large tables.
Most correlated subqueries in a SELECT list can and should be rewritten as a JOIN or window function, which the engine executes in a single pass.
SQL-A-05ADVANCED
Explain transaction isolation levels.
Level
Prevents
Read Uncommitted
Nothing (allows dirty reads)
Read Committed
Dirty reads
Repeatable Read
Dirty + non-repeatable reads
Serializable
All, incl. phantom reads
Higher isolation = more correctness, less concurrency. Most OLTP systems default to Read Committed and opt into stricter levels where needed.
SQL-A-06ADVANCED
Compute median and percentiles in SQL.
SELECT
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY amount) AS median,
PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY amount) AS p95
FROM orders;
PERCENTILE_CONT interpolates between rows; PERCENTILE_DISC returns an actual stored value. Why AVG isn't enough: one whale order drags the mean; the median tells you about the typical order. p95/p99 are the language of latency SLOs.
SQL-A-07ADVANCED
Sessionise raw click events (30-minute inactivity gap).
Two windows: flag a new session when the gap from the previous event exceeds the threshold, then running-sum the flags into session ids.
WITH flags AS (
SELECT user_id, ts,
CASE WHEN ts - LAG(ts) OVER (PARTITION BY user_id ORDER BY ts)
> INTERVAL '30 minutes'
OR LAG(ts) OVER (PARTITION BY user_id ORDER BY ts) IS NULL
THEN 1 ELSE 0 END AS new_sess
FROM clicks
)
SELECT user_id, ts,
SUM(new_sess) OVER (PARTITION BY user_id ORDER BY ts
ROWS UNBOUNDED PRECEDING) AS session_id
FROM flags;
Follow-ups
Session metrics after this? Group by (user_id, session_id): duration, events per session, entry/exit page.
SQL-A-08ADVANCED
Walk an org hierarchy with a recursive CTE and return each employee's depth and path.
WITH RECURSIVE tree AS (
SELECT emp_id, name, manager_id, 1 AS depth,
name::text AS path
FROM emp WHERE manager_id IS NULL
UNION ALL
SELECT e.emp_id, e.name, e.manager_id, t.depth + 1,
t.path || ' > ' || e.name
FROM emp e JOIN tree t ON e.manager_id = t.emp_id
)
SELECT * FROM tree ORDER BY path;
Mention the safety rail: a cycle in the data loops forever β guard with a depth limit (WHERE depth < 20) or cycle detection where the dialect supports it.
SQL-A-09ADVANCED
Why can two runs of the same query return rows in different order β and when is that a bug?
Without ORDER BY, SQL results are sets β order is undefined and depends on plan, parallelism and storage. It becomes a real bug when combined with LIMIT (different "top 10" each run), pagination, or ROW_NUMBER ties without a deterministic tie-breaker β "pick the latest per key" silently picks differently across runs.
Fix: always give windows and pagination a complete, deterministic ordering (append a unique key to the ORDER BY).
SQL-A-10ADVANCED
UPDATE with a JOIN: apply values from another table.
-- Standard / Snowflake
UPDATE target t
SET price = s.new_price
FROM staging s
WHERE t.product_id = s.product_id;
The correctness question interviewers probe: what if staging has two rows for one product? Behaviour is engine-dependent (error, or arbitrary winner). Guarantee the source is unique on the join key first β or use MERGE, which errors on multiple matches by design.
SQL-A-11ADVANCED
Clustered vs non-clustered indexes β explain like you've actually used them.
Context: your OLTP orders table is slow on both WHERE order_id = ? and WHERE customer_id = ? AND order_date > ?. What indexing do you propose?
Clustered index = the table itself, physically sorted by the key. One per table (data can only be in one order). Lookups on the clustered key are fastest β the index is the data; range scans on it read contiguous pages.
Non-clustered index = a separate sorted structure of (key β row locator). Many per table. A lookup finds the entry, then follows the pointer to the row β one extra hop, unless the index covers the query:
-- covering index: the query is answered entirely from the index
CREATE INDEX ix_cust_date ON orders(customer_id, order_date) INCLUDE (total);
Proposal for the scenario: clustered on order_id (the identity, monotonic β no page splits), non-clustered on (customer_id, order_date) including hot columns. Then the two caveats that show depth: every non-clustered index carries the clustered key inside it (keep the clustered key narrow), and column order in composite indexes follows the leftmost-prefix rule β (customer_id, order_date) serves customer lookups, not date-only lookups.
SQL-A-12ADVANCED
A production query got slow. Walk me through your optimization method, step by step.
Context: a report that ran in 40s now takes 9 minutes. Nobody changed the query. Go.
Measure, don't guess. Get the actual execution plan / query profile β not the estimated one.
Find the dominant cost. One node usually owns 80%+: a scan that should be a seek, a spill, an exploding join.
Check the usual suspects in order: stale statistics (plan built for old data sizes); an implicit cast or function on a filtered column killing index/pruning (WHERE YEAR(dt)=2026 β rewrite as a range); a join fan-out multiplying rows; SELECT * dragging columns that prevent a covering index.
Fix the biggest thing only, re-measure. One change at a time or you learn nothing.
Then structural options: better index/clustering, pre-aggregation, partition pruning, rewriting the query shape.
Why did it slow down without a query change? Data grew past a threshold and the plan flipped, or statistics went stale, or parameter sniffing cached a plan for an unrepresentative parameter. Naming those three unprompted is what "advanced" sounds like.
SQL-A-13ADVANCED
EXPLAIN, the profiler, and plan problems β what do you actually look at?
EXPLAIN shows the plan (join order, access methods, estimated rows); EXPLAIN ANALYZE runs it and shows actual rows and time per node. "The profiler" is the engine's tracing tool β SQL Server Profiler/Extended Events, MySQL's SHOW PROFILE/performance schema, Snowflake's Query Profile UI.
What to read, in order:
Estimated vs actual rows. Off by 100Γ? The optimiser is flying blind β stale stats or unindexable predicates.
Access methods. Full scan where you expected a seek; check the predicate is sargable.
Join types. A nested-loop over millions of rows = wrong plan; hash join expected.
Spills. Memory exceeded, work went to disk β the silent killer in warehouses.
Related SQL Server nugget: EXEC proc WITH RECOMPILE forces a fresh plan β the standard test for parameter sniffing (a cached plan optimised for an atypical parameter value).
SQL-A-14ADVANCED
What is a deadlock? How do you diagnose and prevent one?
Context: every night, the payments job and the refunds job occasionally kill each other with "deadlock victim" errors.
A deadlock: transaction 1 holds a lock on A and wants B; transaction 2 holds B and wants A. Neither can proceed; the engine detects the cycle and kills one (the "victim"), rolling it back.
Diagnose: the deadlock graph (SQL Server Extended Events, Postgres logs, SHOW ENGINE INNODB STATUS) shows both queries and the objects locked.
Prevent, in order of effectiveness:
Access resources in a consistent order β if every job touches accounts then payments (alphabetical, by id β any fixed rule), cycles cannot form. This fixes the nightly-jobs scenario.
Keep transactions short β don't hold locks across API calls or user think-time.
Index the columns your WHEREs use β unindexed updates lock far more rows than intended.
Retry logic β deadlock victims are safe to re-run; production code should retry automatically.
SQL-A-15ADVANCED
Lock types, and optimistic vs pessimistic concurrency.
Lock types:shared (many readers coexist), exclusive (one writer, blocks everyone), update (SQL Server's read-intending-to-write, prevents a common deadlock pattern), plus intent locks at coarser granularity (row β page β table) so the engine can escalate cheaply.
Pessimistic: lock first, work, release β nobody else touches the row meanwhile (SELECT β¦ FOR UPDATE). Right when conflicts are frequent and retries expensive.
Optimistic: don't lock; check at commit whether the row changed (version column / timestamp) and retry if it did:
UPDATE accounts SET balance = 900, version = version + 1
WHERE id = 7 AND version = 41; -- 0 rows updated β someone got there first β retry
Right when conflicts are rare β web apps overwhelmingly use this. Warehouses (Snowflake/Delta) are optimistic at the table-version level for the same reason.
SQL-A-16ADVANCED
What is query caching, and what does it look like in a modern warehouse?
Storing a query's result so an identical re-run returns instantly without executing. Three layers to name in Snowflake terms:
Result cache β exact same query text, data unchanged β served in milliseconds, no compute cost, valid ~24h.
Local disk cache β the warehouse keeps recently read micro-partitions warm; different query, same data β still faster.
Metadata cache β COUNT(*), MIN/MAX can be answered from statistics alone.
Two practical implications: dashboards that re-issue identical SQL are nearly free β parameterise consistently to hit the cache; and when benchmarking, disable the result cache or you're measuring the cache, not the query. (MySQL's old global query cache was removed in 8.0 β it serialised writes; know that if MySQL comes up.)
SQL-A-17ADVANCED
Optimistic vs pessimistic locking.
Definition: two strategies for handling concurrent writes to the same row.
Pessimistic β lock the row up front (SELECT β¦ FOR UPDATE), work, release. Others wait. Best when conflicts are frequent and retries costly.
Optimistic β no lock; at commit, check a version/timestamp and retry if it changed. Best when conflicts are rare.
UPDATE accounts SET balance=900, version=version+1
WHERE id=7 AND version=41; -- 0 rows updated β someone beat us β retry
Most web apps and warehouses (Snowflake, Delta) are optimistic.
SQL-A-18ADVANCED
Explain the different lock types.
Shared (S) β many readers can hold it together.
Exclusive (X) β one writer; blocks all others.
Update (U) β read intending to write; prevents a common deadlock between two would-be writers.
Intent locks β let the engine escalate granularity row β page β table.
Understanding lock compatibility explains why readers and writers block each other under some isolation levels.
SQL-A-19ADVANCED
How do you design a database for an e-commerce platform?
Facts join on the surrogate key captured at load time, freezing historical context.
Type 1 overwrites (no history); Type 2 keeps full history.
SQL-A-27ADVANCED
How do you read a query execution plan?
Method: read for the dominant cost and bad estimates.
Compare estimated vs actual rows β big gaps mean stale statistics.
Look for scans where an index seek was expected.
Watch join algorithm choices and spills to disk (memory pressure).
SQL-A-28ADVANCED
What is parameter sniffing?
Definition: the engine caches a plan built for the first parameter value it sees, which may be terrible for other values.
Symptom: the same query is fast for some inputs, slow for others.
Fixes: recompile, OPTIMIZE FOR hints, or local variables to force a generic plan.
SQL-A-28bADVANCED
What makes a predicate "sargable"?
Definition: sargable = Search-ARGument-able = able to use an index. Wrapping the column in a function breaks it.
WHERE YEAR(order_date) = 2026 -- NOT sargable (scan)
WHERE order_date >= '2026-01-01'
AND order_date < '2027-01-01' -- sargable (seek)
Keep the indexed column bare on one side of the comparison.
SQL-A-29ADVANCED
What is partitioning and how does it help?
Definition: splitting a large table into segments by a key, usually date.
Queries prune irrelevant partitions, scanning far less data.
Archival is cheap β drop an old partition instead of a big DELETE.
Choose the partition key to match how you filter (usually time).
SQL-A-30ADVANCED
What is clustering in a warehouse (e.g. Snowflake)?
Definition: physically ordering data by a key so scans can skip micro-partitions that can't contain matches.
Improves pruning on the clustering key β big scan reductions.
Has a maintenance cost as data changes (re-clustering).
Cluster on the column you filter/range on most.
SQL-A-31ADVANCED
Solve the gaps-and-islands problem.
Technique: subtract ROW_NUMBER from the sequence value β the difference is constant within a consecutive run ("island").
SELECT id, MIN(dt) AS start, MAX(dt) AS end
FROM (
SELECT id, dt,
dt - (ROW_NUMBER() OVER (PARTITION BY id ORDER BY dt)) * INTERVAL '1 day' AS grp
FROM events
) x
GROUP BY id, grp;
SQL-A-32ADVANCED
Sessionise events by an inactivity gap.
Technique: start a new session when the gap from the previous event exceeds a threshold, then running-sum the flags.
SELECT *,
SUM(is_new) OVER (PARTITION BY user_id ORDER BY ts) AS session_id
FROM (
SELECT *,
CASE WHEN ts - LAG(ts) OVER (PARTITION BY user_id ORDER BY ts)
> INTERVAL '30 min' THEN 1 ELSE 0 END AS is_new
FROM events
) x;
SQL-A-33ADVANCED
Compute percentiles and explain their business use.
SELECT PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY latency_ms) AS p95
FROM requests;
Why it matters: p95/p99 describe the tail of a distribution. An average latency hides the slow experiences one big outlier would mask β SLAs are written on percentiles, not means.
SQL-A-34ADVANCED
Logical vs physical query processing order.
Definition: the order you write clauses is not the order they execute.
Logical order: FROM β WHERE β GROUP BY β HAVING β SELECT β ORDER BY.
Explains why a SELECT alias works in ORDER BY but not in WHERE.
Explains why aggregates can't be filtered in WHERE.
SQL-A-35ADVANCED
Hash join vs nested-loop vs merge join.
Nested-loop β best for small inputs or an indexed lookup on the inner side.
Hash join β builds a hash table on one side; best for large unsorted inputs.
Merge join β best when both inputs are already sorted on the key.
The optimiser picks based on size, sortedness and indexes β recognising them in a plan tells you why a query is slow.
SQL-A-36ADVANCED
What causes a join to "fan out" and inflate results?
Definition: joining to a non-unique key multiplies rows β each fact row matches several dimension rows.
SUM then double-counts β a correctness bug, not just performance.
Fix the grain (de-duplicate the dimension); never mask it with DISTINCT.
SQL-A-37ADVANCED
What are the transaction isolation levels?
Level
Prevents
Read Uncommitted
nothing (allows dirty reads)
Read Committed
dirty reads
Repeatable Read
+ non-repeatable reads
Serializable
+ phantom reads
Higher isolation = more correctness, less concurrency. Read Committed is the common default.
SQL-A-38ADVANCED
What is a phantom read?
Definition: re-running the same range query within a transaction returns new rows another transaction inserted.
Differs from a non-repeatable read (which is a changed row, not a new one).
Prevented only at Serializable isolation.
SQL-A-39ADVANCED
What is MVCC (multi-version concurrency control)?
Definition: writers create new row versions while readers see a consistent snapshot of old ones.
Readers don't block writers and vice versa β high concurrency.
Used by Postgres, Oracle, Snowflake.
Cost: old versions must be cleaned up (e.g. Postgres VACUUM).
SQL-A-40ADVANCED
How do you optimize a slow query, step by step?
Capture the actual execution plan and find the dominant-cost node.
Check for stale statistics, non-sargable predicates, fan-out, and disk spills.
Confirm useful indexes exist for the filters/joins.
Change one thing, re-measure β don't shotgun several changes at once.
SQL-A-41ADVANCED
Composite index and the leftmost-prefix rule.
Definition: an index on (a,b,c) can serve lookups on a, (a,b), (a,b,c) β but not on b alone.
Order columns by how you filter (equality columns first, then range).
Explains why a seemingly-relevant index isn't used.
SQL-A-42ADVANCED
What are statistics and why do they matter?
Definition: metadata about data distribution the optimiser uses to estimate row counts and choose a plan.
Stale stats β wrong estimates β bad plans (e.g. a loop where a hash join was needed).
A suddenly-slow query after a big load is often fixed by refreshing statistics.
SQL-A-43ADVANCED
What is a recursive query's termination risk?
Definition: a cycle in the data makes a recursive CTE loop forever.
Guard with a depth counter and a limit, or explicit cycle detection.
Real hierarchies (org charts) can contain accidental loops β defend against them.
SQL-A-44ADVANCED
Implement efficient pagination on large tables.
Technique: keyset (seek) pagination instead of large OFFSET.
SELECT * FROM events
WHERE (created, id) > (:last_created, :last_id)
ORDER BY created, id
LIMIT 20;
Each page reads only the next 20 rows using the index β constant time regardless of depth.
SQL-A-45ADVANCED
Why is OFFSET pagination slow at high offsets?
Reason:OFFSET 100000 still computes and discards all 100,000 skipped rows before returning the next page.
Cost grows with page depth.
Keyset pagination avoids it by seeking to the last-seen key.
SQL-A-46ADVANCED
What is query caching in a modern warehouse?
Definition: layered caches that shortcut repeated work.
Result cache β identical query + unchanged data β instant.
Local/disk cache β warm micro-partitions on the compute node.
Metadata cache β COUNT/MIN/MAX served from statistics.
SQL-A-47ADVANCED
OLTP vs OLAP.
OLTP
OLAP
Workload
many small txns
few large reads
Model
normalized
denormalized
Data
current state
historical
Different goals drive different schema and storage choices.
SQL-A-48ADVANCED
Surrogate key vs natural key.
Natural β a real-world unique attribute (email, ISBN).
Surrogate β a system-generated id with no business meaning.
Surrogates stay stable when business values change (an email is edited), and keep joins narrow β the usual warehouse choice.
SQL-A-49ADVANCED
Detect and remove duplicate rows at scale.
DELETE FROM t WHERE id IN (
SELECT id FROM (
SELECT id, ROW_NUMBER() OVER (PARTITION BY key_cols ORDER BY id) rn
FROM t
) x WHERE rn > 1);
On very large tables, building a clean copy and swapping can beat a giant DELETE. Add a uniqueness test afterward to prevent recurrence.
SQL-A-50ADVANCED
Clustered vs covering index.
Clustered β the table itself stored in key order; one per table.
Covering β a non-clustered index containing all columns a query needs, so it never touches the base table.
Different tools: clustered defines physical order; covering eliminates lookups for a specific query.
SQL-A-51ADVANCED
Handle late-arriving data in a warehouse.
Reprocess the affected partitions idempotently rather than the whole table.
Use MERGE keyed on the business grain to update in place.
Keep an event timestamp separate from the load timestamp so late data lands in the right period.
SQL-A-52ADVANCED
What is an idempotent data load and why does it matter?
Definition: running the same load twice yields the same result β no duplicates, no drift.
Achieved with MERGE or delete-then-insert on a batch key.
Essential because retries and backfills happen constantly in real pipelines.
Technique: DISTINCT isn't allowed in most window frames, so reduce each entity to its first-seen date, then running-count those.
WITH firsts AS (
SELECT customer_id, MIN(order_date) AS first_dt FROM orders GROUP BY customer_id)
SELECT first_dt,
COUNT(*) OVER (ORDER BY first_dt ROWS UNBOUNDED PRECEDING) AS cumulative_customers
FROM firsts;
SQL-A-55ADVANCED
What is a bitmap index and when is it useful?
Definition: stores a bitmap per distinct value β efficient for low-cardinality columns.
Great for warehouse filtering (status, gender, flags) and cheap AND/OR combining.
Poor for high-write OLTP β every change touches many bitmaps.
SQL-A-56ADVANCED
What is predicate pushdown?
Definition: applying filters as early as possible β ideally at the storage scan β so less data is read and moved.
Central to columnar/warehouse performance and partition pruning.
Wrapping a partition column in a function defeats it.
SQL-A-57ADVANCED
Row vs columnar storage.
Row β stores whole rows together; fast for single-record OLTP reads/writes.
Columnar β stores each column together; fast for analytical scans over a few columns, and compresses far better.
Warehouses are columnar because analytics reads few columns across many rows.
SQL-A-58ADVANCED
Reconcile two tables that should match.
SELECT COALESCE(a.id,b.id) AS id,
CASE WHEN a.id IS NULL THEN 'missing in A'
WHEN b.id IS NULL THEN 'missing in B'
ELSE 'value mismatch' END AS issue
FROM a FULL OUTER JOIN b ON a.id=b.id
WHERE a.id IS NULL OR b.id IS NULL OR a.amt IS DISTINCT FROM b.amt;
Surfaces missing rows on either side and value mismatches in one query.
SQL-A-59ADVANCED
What is IS DISTINCT FROM and why use it?
Definition: a NULL-safe inequality β true when values differ, treating two NULLs as equal and one NULL vs a value as different.
Plain <> returns NULL (not true) when one side is NULL, silently missing changes.
Essential for change-data-capture and reconciliation.
SQL-A-60ADVANCED
Pivot and unpivot data.
Pivot β rows β columns via conditional aggregation (SUM(CASE β¦)).
Unpivot β columns β rows via UNION ALL of one SELECT per column, or the UNPIVOT operator.
Conditional aggregation is the portable form that works in every dialect.
SQL-A-61ADVANCED
View vs CTE for reuse.
View β permanent, shared across queries, sessions and users.
CTE β scoped to a single statement.
If logic is reused across many queries, promote it to a view (or a table/materialized view); a CTE only helps within one statement.
SQL-A-62ADVANCED
Find the top N% of rows.
SELECT * FROM (
SELECT *, NTILE(100) OVER (ORDER BY score DESC) AS pct
FROM t
) x WHERE pct <= 5; -- top 5%
NTILE(100) buckets rows into percentiles; filtering pct <= 5 returns the top 5%.
SQL-A-63ADVANCED
What is a correlated UPDATE and its risk?
Definition: updating rows using a subquery that references each target row.
If the source returns multiple matches per target, behaviour is engine-dependent (error or arbitrary value).
Guarantee source uniqueness on the join key, or use MERGE for defined semantics.
SQL-A-64ADVANCED
What factors guide a relational database design?
Normalization and data integrity (constraints, keys).
Access patterns β design for the queries you'll actually run.
Scalability, security, and room to evolve.
Right data types and indexing.
Good design balances normalization against the real read/write workload.
SQL-A-65ADVANCED
Handle concurrent access to the same record.
Pessimistic locks when conflicts are frequent.
Optimistic versioning/timestamps when conflicts are rare.
Appropriate isolation level to prevent the specific anomaly you care about.
Choose based on conflict frequency and the cost of retrying.
SQL-A-66ADVANCED
What is the role of a DBA?
Definition: the person responsible for a database's health across its lifecycle.
Installs, configures, secures and monitors the database.
Manages backups, recovery, performance tuning and capacity.
Increasingly overlaps with data/platform engineering in cloud setups.
Scenario-based real situations
No lookup answer β the interviewer is watching your method.
SQL-S-01SCENARIO
A daily report's totals silently changed since last week, though no code was deployed. How do you investigate with SQL?
Reproduce with the exact filters first. Then work the likely causes: late-arriving rows for that date, a Type-1 dimension overwrite that re-rolls history, duplicates introduced upstream, or non-deterministic logic like CURRENT_DATE in the model.
Prove it by diffing against a point-in-time snapshot (Snowflake Time Travel):
SELECT * FROM fact_sales AT(TIMESTAMP => '2026-07-24 06:00'::timestamp)
MINUS
SELECT * FROM fact_sales;
SQL-S-02SCENARIO
Duplicate rows appeared in a fact table overnight. Walk me through it.
Stabilise (warn consumers), then quantify with a grain check:
SELECT key_cols, COUNT(*) n, COUNT(DISTINCT batch_id) batches
FROM fact GROUP BY key_cols HAVING COUNT(*) > 1;
Same batch_id β the load or a join fan-out duplicated them. Different batches β the pipeline ran twice without idempotency. Fix by de-duplicating to the latest per key, then prevent with a uniqueness test on every load and a MERGE instead of append.
SQL-S-03SCENARIO
You're given a 200-line query that "sometimes returns wrong numbers". Where do you start?
Don't read top-to-bottom. Find where row counts change unexpectedly β join by join. The usual culprit is a join to a non-unique key inflating a measure. Isolate each CTE, count its output, and compare to what you expect. "Sometimes" almost always means a data-dependent fan-out or a NULL-handling edge case, not random behaviour.
SQL-S-04SCENARIO
Reconcile two tables that should match but don't (e.g. source vs warehouse).
A FULL OUTER JOIN on the key surfaces both missing and extra rows; IS DISTINCT FROM compares columns NULL-safely:
SELECT COALESCE(s.id,w.id) id,
CASE WHEN w.id IS NULL THEN 'missing in warehouse'
WHEN s.id IS NULL THEN 'extra in warehouse'
ELSE 'value mismatch' END AS issue
FROM source s FULL OUTER JOIN warehouse w ON s.id = w.id
WHERE s.id IS NULL OR w.id IS NULL
OR s.amount IS DISTINCT FROM w.amount;
SQL-S-05SCENARIO
A dashboard query is fast all week but times out every Monday morning. Why?
Reason from the pattern: Monday-only means something weekly. Candidates to check, in order: cold caches after a weekend warehouse suspend (first user pays full price); weekend batch backlog β Monday processes 3 days of data; contention with weekly jobs scheduled Monday 6am; and statistics/clustering degraded by large weekend loads.
Diagnose by comparing Monday vs Tuesday query profiles: same plan but slower = contention/cache; different plan or 3Γ bytes scanned = data volume. The fix follows the cause β don't guess first.
SQL-S-06SCENARIO
After adding a "harmless" join to enrich a report, its total revenue grew 8%. Nothing else changed. Explain and fix.
The new join fanned out: the joined table isn't unique on the join key, so some fact rows now appear twice and SUM counts them twice. 8% is exactly the share of duplicated keys.
Prove it: SELECT key, COUNT(*) FROM dim GROUP BY key HAVING COUNT(*)>1. Fix properly: de-duplicate the dimension to one row per key (latest by timestamp) before joining β not DISTINCT after, and not dividing by two.
SQL-S-07SCENARIO
Design the database for an e-commerce platform. Talk me through it.
Structure the answer: entities β relationships β keys β the hard parts.
The details that score: orders β products is many-to-many, resolved by order_items with a composite key; unit_price is copied into order_items β the price at purchase time must survive later price changes (a deliberate, correct denormalization); category hierarchy via self-referencing FK; money as DECIMAL; and index the access paths (orders by user, products by category), FKs for referential integrity.
SQL-S-08SCENARIO
What factors guide a relational design β and how do you model many-to-many relationships?
The factor checklist, each with a "because": normalization (store each fact once β updates can't disagree); integrity via keys and constraints (the schema enforces rules apps forget); access patterns (design for the queries you'll actually run β this decides indexes); scalability (data types sized right, partitioning strategy for the big tables); security (schemas as permission boundaries); evolution (adding a column shouldn't mean rewriting the app).
Many-to-many: relational tables can't hold it directly β a junction (bridge) table with two FKs resolves it into two one-to-many relationships:
Bonus point: relationship attributes (grade, enrolled_at) live on the junction table β they belong to the pair, not to either side.
SQL-S-09SCENARIO
When would you choose a stored procedure over a plain query?
Definition: a stored procedure is precompiled, parameterised logic living in the database. The choice is about reuse and control, not raw querying.
Choose a procedure when:
The same multi-step logic runs repeatedly (month-end processing, batch jobs).
Several statements must succeed together with error handling and a transaction.
You want applications to call one safe, permission-controlled entry point instead of touching tables directly.
Stick with a plain query when the logic is a one-off read, or when your stack uses dbt/orchestration for transformations β increasingly common in modern warehouses.
SQL-S-10SCENARIO
When is a trigger the right tool β and when is it a trap?
Definition: a trigger runs automatically on INSERT/UPDATE/DELETE, guaranteeing a side-effect regardless of which application caused the change.
Right tool for:
Audit logging β write a history row on every change, unforgettable.
Enforcing an integrity rule that constraints can't express.
Trap when: used for business workflow. Triggers hide logic, so a row mysteriously changing becomes hard to debug. Rule of thumb: triggers for integrity/audit, not for application behaviour.
SQL-S-11SCENARIO
A daily report's totals changed with no code deploy. How do you investigate?
Situation: numbers moved but nobody shipped SQL β so the data or its timing changed, not the query.
Method, in order:
Reproduce with the exact same filters and date range.
Check late-arriving rows for that period landing after the last run.
Check a Type-1 dimension overwrite that silently re-rolls history.
Check upstream duplicates, and non-deterministic logic (e.g. CURRENT_DATE in the model).
Prove it by diffing against a point-in-time snapshot (e.g. Snowflake Time Travel).
SQL-S-12SCENARIO
You combine two tables that may have duplicates β UNION or UNION ALL?
Definition: both stack rows vertically; they differ on duplicate handling and cost.
UNION ALL β keeps everything, just concatenates. Fast.
UNION β removes duplicates across both sets via an extra sort/hash pass. Slower.
Decision: default to UNION ALL and only use UNION when you genuinely need cross-set de-duplication. Reaching for UNION by reflex is a common performance smell.
SQL-S-13SCENARIO
Two nightly jobs deadlock intermittently. How do you fix it?
Situation: job A holds a lock B needs while job B holds one A needs β a cycle; the engine kills one as the "victim".
Fix, most effective first:
Access shared tables in a consistent order in both jobs β a cycle then cannot form.
Keep transactions short so locks are held briefly.
Index the columns the updates filter on β unindexed updates lock more rows than intended.
Add automatic retry β deadlock victims are safe to re-run.
SQL-S-14SCENARIO
A query is fast all week but times out Monday morning. Why?
Reasoning: a Monday-only pattern points to something weekly.
Candidates to check:
Cold caches after a weekend warehouse suspend β the first user pays full price.
Weekend batch backlog β Monday processes several days of data at once.
Contention with weekly jobs scheduled early Monday.
Statistics/clustering degraded by large weekend loads.
Diagnose: compare Monday vs Tuesday query profiles β same plan but slower β cache/contention; different plan or 3Γ bytes scanned β data volume.
SQL-S-15SCENARIO
Adding a join grew total revenue 8%. Explain and fix.
Cause: the joined table isn't unique on the join key, so it fanned out β some fact rows now appear twice and SUM double-counts them. The 8% is the share of duplicated keys.
Prove:SELECT key, COUNT(*) FROM dim GROUP BY key HAVING COUNT(*)>1;
Fix properly: de-duplicate the dimension to one row per key (latest by timestamp) before joining β not DISTINCT after, and never "divide by two".
SQL-S-16SCENARIO
You must migrate a huge table with minimal downtime. Approach?
Goal: change structure without a long lock or outage.
Method:
Create the new table/structure alongside the old.
Backfill existing data in batches to avoid one giant transaction.
Keep both in sync during the window (dual-write or a trigger/CDC).
Verify row counts and checksums match.
Swap with a quick rename β reversible until this final step.
SQL-S-17SCENARIO
A dashboard shows different numbers than the source system. How do you reconcile?
Method: surface both missing and mismatched rows in one pass.
SELECT COALESCE(s.id,w.id) AS id,
CASE WHEN w.id IS NULL THEN 'missing in warehouse'
WHEN s.id IS NULL THEN 'extra in warehouse'
ELSE 'value mismatch' END AS issue
FROM source s
FULL OUTER JOIN warehouse w ON s.id = w.id
WHERE s.id IS NULL OR w.id IS NULL
OR s.amount IS DISTINCT FROM w.amount;
Classify each difference, then trace the largest category upstream first.
SQL-S-18SCENARIO
Duplicate rows appeared in a fact table overnight. Walk through it.
Stabilise then quantify with a grain check:
SELECT key_cols, COUNT(*) n, COUNT(DISTINCT batch_id) batches
FROM fact GROUP BY key_cols HAVING COUNT(*) > 1;
Same batch_id β a load or join fan-out duplicated rows.
Different batches β the pipeline ran twice without idempotency.
Fix: de-duplicate to the latest per key, then prevent recurrence with a uniqueness test on every load and a MERGE instead of blind append.
SQL-S-19SCENARIO
A 200-line query "sometimes" returns wrong numbers. Where do you start?
Approach: don't read top-to-bottom β hunt for where the row count changes unexpectedly.
Isolate each CTE/subquery and count its output rows.
Compare each count to what you expect at that grain.
The usual culprit is a join to a non-unique key inflating a measure.
Key insight: "sometimes" almost always means a data-dependent fan-out or a NULL edge case β not randomness.
SQL-S-20SCENARIO
How would you design a schema for a ride-hailing app?
Details that score: trips is the central fact; snapshot the fare and pickup/drop locations onto the trip so later rate changes don't rewrite history; index by user, driver and time for the common lookups.
SQL-S-21SCENARIO
A COUNT(*) on a billion-row table is too slow. Options?
Clarify first: does the business need an exact count or an estimate?
Approximate β read the row count from table metadata/statistics (instant).
Maintained counter β keep a running total updated by triggers or the pipeline.
Scoped count β count a single partition instead of the whole table.
Exact counts on huge tables are inherently expensive; naming the trade-off is the point.
SQL-S-22SCENARIO
How do you prevent an accidental mass UPDATE/DELETE in production?
The discipline (the real answer):
Preview the WHERE as a SELECT first to see the blast radius.
Wrap the change in a transaction.
Verify the affected-row count matches the preview before committing.
For very large changes, batch them to limit lock and undo size.
The statements are trivial; the safety habit is what's being tested.
SQL-S-23SCENARIO
Users report stale data on a dashboard. What could cause it?
Likely causes, each with a check:
A materialized view not refreshed β check its refresh schedule.
A result cache serving old output β check cache invalidation / query text.
A pipeline that failed or hasn't run β check the last successful load time.
Trace from "when was the data last updated?" backwards to the layer that stopped.
SQL-S-24SCENARIO
How would you store and query historical price changes?
Design: an SCD Type 2 dimension that keeps history instead of overwriting.
SELECT price FROM product_price
WHERE product_id = :pid
AND :as_of_date >= valid_from
AND (:as_of_date < valid_to OR valid_to IS NULL);
SQL-S-25SCENARIO
A JOIN returns fewer rows than expected. What do you check?
Checklist:
An INNER JOIN silently dropping unmatched rows β did you need LEFT?
A filter on the right table in WHERE turning a LEFT JOIN into an INNER.
Type mismatch on the join key (e.g. text vs int) preventing matches.
NULLs in the join column β NULL never equals NULL.
SQL-S-26SCENARIO
Find the most recent status per order in a status-history table.
Pattern: "latest per group" β rank each order's statuses newest-first, keep rank 1.
SELECT order_id, status, status_time
FROM (
SELECT *, ROW_NUMBER() OVER (PARTITION BY order_id
ORDER BY status_time DESC) rn
FROM order_status
) t
WHERE rn = 1;
One of the most reused window-function patterns in analytics.
SQL-S-27SCENARIO
Your ETL sometimes loads the same file twice. How do you make it safe?
Goal: make the load idempotent β running it twice yields the same result, no duplicates.
MERGE on a batch/business key so re-loading updates rather than appends.
Or delete-then-insert for that batch id in one transaction.
Track processed file names/hashes so an already-loaded file is skipped.
SQL-S-28SCENARIO
How would you calculate month-over-month customer retention?
Definition: retention = customers active this month who were also active last month, Γ· last month's active customers.
Method:
Build a distinct (customer, month) activity set.
Self-join it to itself on customer where month = previous month + 1.
Count the matched customers per month and divide by the prior month's total.
SQL-S-29SCENARIO
A column that should be unique has duplicates. What now?
Steps:
Quantify: GROUP BY col HAVING COUNT(*) > 1.
Decide which row to keep (usually latest by timestamp).
De-duplicate using ROW_NUMBER and delete rn > 1.
Add a UNIQUE constraint so it can't recur.
Fixing the data without adding the constraint means you'll be back next week.
SQL-S-30SCENARIO
Design a many-to-many between students and courses.
Solution: a junction table resolves the many-to-many into two one-to-many links.
Key point: relationship attributes like grade and enrolled_at belong on the junction table β they describe the pair, not either side.
SQL-S-31SCENARIO
A query works in dev but is slow in production. Likely reasons?
Compare the two execution plans first, then check:
Far larger data volume in prod flipping the optimal plan.
Different or stale statistics in prod.
Missing indexes that exist in dev.
A cached plan or parameter sniffing serving a bad plan.
Same plan but slower β data/resources; different plan β stats/indexes.
SQL-S-32SCENARIO
How would you detect and report data quality issues?
Automated checks, run per load:
Null rate on required columns above a threshold.
Duplicate business keys (GROUP BY β¦ HAVING COUNT(*)>1).
Referential orphans (foreign keys with no parent).
Out-of-range or impossible values.
Row-count delta vs the previous load (sudden spikes/drops).
Surface results as a daily data-quality report so issues are caught before consumers see them.
SQL-S-33SCENARIO
Find customers whose spend dropped versus last month.
Method: compare each customer's month to their previous month with LAG.
WITH m AS (
SELECT customer_id, DATE_TRUNC('month', order_date) AS mth,
SUM(total_amount) AS spend
FROM orders GROUP BY customer_id, DATE_TRUNC('month', order_date)
)
SELECT customer_id, mth, spend,
LAG(spend) OVER (PARTITION BY customer_id ORDER BY mth) AS prev_spend
FROM m
QUALIFY spend < LAG(spend) OVER (PARTITION BY customer_id ORDER BY mth);
A churn-risk signal. Without QUALIFY, wrap in a subquery and filter.
Practical β hands-on solve it yourself
Write the query against the schema, then reveal the solution.
Return each row plus a cumulative running total of amount within each store, ordered by date.
SELECT store_id, sale_date, amount,
SUM(amount) OVER (PARTITION BY store_id ORDER BY sale_date
ROWS UNBOUNDED PRECEDING) AS running_total
FROM sales
ORDER BY store_id, sale_date;
Key idea: an ordered window with an explicit ROWS frame avoids the default-frame tie trap.
SQL-P-02PRACTICE
Nth-highest value with ties handled.
scores(player TEXT, points INT)
Return the player(s) with the 3rd-highest distinct score.
SELECT player, points
FROM (
SELECT player, points,
DENSE_RANK() OVER (ORDER BY points DESC) AS rk
FROM scores
) t
WHERE rk = 3;
DENSE_RANK gives the 3rd distinct value; ties at that value all qualify.
SQL-P-03PRACTICE
Month-over-month growth %.
revenue(month DATE, amount DECIMAL)
For each month, show the amount, the previous month's amount, and the % change.
SELECT month, amount,
LAG(amount) OVER (ORDER BY month) AS prev,
ROUND(100.0 * (amount - LAG(amount) OVER (ORDER BY month))
/ NULLIF(LAG(amount) OVER (ORDER BY month), 0), 1) AS pct_change
FROM revenue
ORDER BY month;
NULLIF(β¦,0) guards against divide-by-zero on the first/empty month.
SQL-P-04PRACTICE
Find customers who bought in Jan but not in Feb.
orders(customer_id INT, order_date DATE)
Return customer_ids present in January but absent in February of the same year.
SELECT DISTINCT customer_id
FROM orders
WHERE order_date >= '2026-01-01' AND order_date < '2026-02-01'
AND customer_id NOT IN (
SELECT customer_id FROM orders
WHERE order_date >= '2026-02-01' AND order_date < '2026-03-01'
AND customer_id IS NOT NULL -- guard the NOT IN NULL trap
);
Prefer NOT EXISTS in production; the explicit NULL guard shows you know the trap.
Return one row per user_id β the most recent by ts.
SELECT id, user_id, ts, payload
FROM (
SELECT *, ROW_NUMBER() OVER (PARTITION BY user_id
ORDER BY ts DESC) AS rn
FROM events
) t
WHERE rn = 1;
On Snowflake/BigQuery, QUALIFY rn = 1 removes the subquery.
SQL-P-06PRACTICE
Pivot quarterly sales into columns.
sales(product TEXT, quarter TEXT, amount DECIMAL)
One row per product, with columns q1βq4 summing the amounts.
SELECT product,
SUM(CASE WHEN quarter='Q1' THEN amount ELSE 0 END) AS q1,
SUM(CASE WHEN quarter='Q2' THEN amount ELSE 0 END) AS q2,
SUM(CASE WHEN quarter='Q3' THEN amount ELSE 0 END) AS q3,
SUM(CASE WHEN quarter='Q4' THEN amount ELSE 0 END) AS q4
FROM sales
GROUP BY product;
Conditional aggregation is portable across every dialect β no PIVOT syntax needed.
Return up to the 3rd-highest revenue rank per category β products tied on revenue share a rank.
SELECT category, product, revenue
FROM (
SELECT category, product, revenue,
DENSE_RANK() OVER (PARTITION BY category
ORDER BY revenue DESC) AS rk
FROM sales
) t
WHERE rk <= 3
ORDER BY category, revenue DESC;
DENSE_RANK keeps ties together; ROW_NUMBER would arbitrarily cut one of two equal products.
SQL-P-08PRACTICE
Find the missing invoice numbers.
invoices(invoice_no INT) -- should be consecutive
Return each gap as (gap_start, gap_end).
SELECT invoice_no + 1 AS gap_start,
next_no - 1 AS gap_end
FROM (
SELECT invoice_no,
LEAD(invoice_no) OVER (ORDER BY invoice_no) AS next_no
FROM invoices
) t
WHERE next_no - invoice_no > 1;
LEAD exposes each number's successor; any jump > 1 brackets a gap.
SQL-P-09PRACTICE
Longest streak of consecutive active days per user.
activity(user_id INT, activity_date DATE) -- one row per active day
Return each user's longest run of consecutive days.
WITH grp AS (
SELECT user_id, activity_date,
activity_date
- ROW_NUMBER() OVER (PARTITION BY user_id
ORDER BY activity_date) * INTERVAL '1 day' AS g
FROM (SELECT DISTINCT user_id, activity_date FROM activity) d
)
SELECT user_id, MAX(len) AS longest_streak
FROM (SELECT user_id, g, COUNT(*) AS len FROM grp GROUP BY user_id, g) s
GROUP BY user_id;
Gaps-and-islands: date minus row_number is constant within a consecutive run. Deduplicate dates first or double-active days break the arithmetic.
SQL-P-10PRACTICE
Customers who ordered in every month of 2026 so far.
orders(customer_id INT, order_date DATE)
Return customers with at least one order in each distinct month present in the table for 2026.
SELECT customer_id
FROM orders
WHERE order_date >= '2026-01-01' AND order_date < '2027-01-01'
GROUP BY customer_id
HAVING COUNT(DISTINCT DATE_TRUNC('month', order_date)) =
(SELECT COUNT(DISTINCT DATE_TRUNC('month', order_date))
FROM orders
WHERE order_date >= '2026-01-01' AND order_date < '2027-01-01');
"Present in every group" = your distinct-month count equals the global distinct-month count. The relational-division pattern.
Learn β course syllabus prototype. The topic structure below is final; lesson content fills in per topic. Notice the options changed: this tab has its own modules, not the interview filters.
Module 1 Β· Basic
Introduction, databases & the relational model
SELECT, WHERE & filtering patterns
Sorting, LIMIT & pagination
Operators, expressions & NULL logic
Aggregations & GROUP BY / HAVING
Joins I β inner, left, right, full
Set operations β UNION, INTERSECT, EXCEPT
Keys, constraints & data types
DDL & DML essentials
Module 2 Β· Intermediate
Joins II β self, anti, semi joins
Subqueries & CTEs (incl. recursive)
Window functions β ranking, offsets, frames
CASE & conditional aggregation
Date & string mastery (MySQL functions)
Views, temp tables & materialized views
Stored procedures & triggers
Transactions & isolation levels
Module 3 Β· Advanced
Indexing deep dive β B-tree, clustered, covering
Reading execution plans & the profiler
Query tuning methodology
Locks, blocking & deadlocks
Partitioning & clustering at scale
Patterns β gaps & islands, sessionisation, SCD
Warehouse SQL β Snowflake specifics
Practice Lab β prototype. Bigger guided projects than the interview practicals: multi-step builds on real datasets (design a schema, load it, answer 15 business questions). Structure ready; projects load in as they're written.
Datasets β prototype. Downloadable practice data used by Learn and the Practice Lab.
employees.csv
10 000 rows Β· 8 columns Β· 480 KB
Departments, managers, salaries β powers the join and window-function exercises.
Download Β· coming with Lab
sales_2026.csv
250 000 rows Β· 11 columns Β· 9 MB
Daily transactions with region, product and customer keys β the aggregation playground.
Download Β· coming with Lab
Notes β prototype. Condensed revision sheets β the one-page versions of each Learn module for the night before.
SQL Window Functions β cheat sheet
1 page Β· PDF + web
Every ranking, offset and frame clause with one worked example each.
Coming soon
Blog β prototype. Articles tied to this course.
Why your LEFT JOIN is secretly an INNER JOIN
8 min read
The ON-vs-WHERE trap, with three production stories.
Coming soon
NOT IN returned zero rows β a post-mortem
6 min read
How one NULL emptied a reconciliation report.
Coming soon
Videos β prototype. Lessons hosted on YouTube, embedded here β the site stays light, the videos stream from YouTube.
βΆWindow functions from zero
βΆJoins masterclass
βΆReading an execution plan
SQL-P-11PRACTICE
Warm-up set: filters & date ranges (5 quick queries).
Write: β employees who started after 1 Jan 2020 Β· β‘ employees who joined in the last 30 days Β· β’ products priced $10β$50 Β· β£ customers with a company.com email Β· β€ products not stocked since last year.
-- β
SELECT * FROM employees WHERE start_date > '2020-01-01';
-- β‘ (MySQL)
SELECT * FROM employees
WHERE start_date >= DATE_ADD(NOW(), INTERVAL -30 DAY);
-- β’ BETWEEN is inclusive on both ends
SELECT * FROM products WHERE price BETWEEN 10 AND 50;
-- β£ leading % β full scan; fine for small tables
SELECT * FROM customers WHERE email LIKE '%@company.com';
-- β€
SELECT * FROM products WHERE last_stock_date < '2025-01-01';
Speed round β interviewers use these to see if syntax is automatic before moving to harder ground.
Write: β order count per customer Β· β‘ customers with more than 10 orders Β· β’ employee count per department Β· β£ total salary of departments whose average salary exceeds 50k Β· β€ total sales per year, and the single best month.
-- β
SELECT customer_id, COUNT(order_id) AS total_orders
FROM orders GROUP BY customer_id;
-- β‘
SELECT customer_id, COUNT(order_id) AS order_count
FROM orders GROUP BY customer_id
HAVING COUNT(order_id) > 10;
-- β’
SELECT department, COUNT(employee_id) AS employee_count
FROM employees GROUP BY department;
-- β£ filter on one aggregate, display another
SELECT department, SUM(salary) AS total_salary
FROM employees GROUP BY department
HAVING AVG(salary) > 50000;
-- β€
SELECT YEAR(order_date) AS yr, SUM(total_amount) AS annual_sales
FROM orders GROUP BY YEAR(order_date);
SELECT MONTH(order_date) AS month, SUM(total_amount) AS sales
FROM orders GROUP BY MONTH(order_date)
ORDER BY sales DESC LIMIT 1;
β£ is the teaching point: HAVING can filter on an aggregate you don't display. And note portable style prefers repeating the aggregate in HAVING over reusing the alias.
Write: β top 5 products by total quantity sold Β· β‘ the 5 most recent orders.
-- β
SELECT product_id, SUM(quantity) AS total_sold
FROM order_details
GROUP BY product_id
ORDER BY total_sold DESC
LIMIT 5;
-- β‘
SELECT * FROM orders ORDER BY order_date DESC LIMIT 5;
Add a tie-breaker (, product_id / , order_id) to make both deterministic β the follow-up they're hoping you'll mention. Ranked-with-ties version: DENSE_RANK, see P-07.
SQL-P-14PRACTICE
Subquery set: comparisons against computed values.
Write: β employees earning above the average salary Β· β‘ employees with the same role as 'Rakesh' Β· β’ orders placed by customers in New York or Los Angeles.
-- β
SELECT * FROM employees
WHERE salary > (SELECT AVG(salary) FROM employees);
-- β‘
SELECT * FROM employees
WHERE role = (SELECT role FROM employees WHERE name = 'Rakesh');
-- β’
SELECT * FROM orders
WHERE customer_id IN (SELECT customer_id FROM customers
WHERE city IN ('New York','Los Angeles'));
β‘ breaks if two Rakeshes exist (scalar subquery, multiple rows) β say you'd add LIMIT 1 or switch to IN depending on intent. That edge-case awareness is the real test.
Write: β give Sales a 10% raise Β· β‘ delete all orders placed before 2021 β as you would in production.
-- 0) preview the blast radius first
SELECT COUNT(*) FROM employees WHERE department = 'Sales';
BEGIN;
UPDATE employees SET salary = salary * 1.10
WHERE department = 'Sales';
-- check affected row count matches the preview
COMMIT; -- or ROLLBACK
BEGIN;
DELETE FROM orders WHERE order_date < '2021-01-01';
COMMIT;
The queries are trivial; the discipline is the answer: preview with SELECT, wrap in a transaction, verify the count, then commit. For huge deletes, mention batching (delete in chunks) to avoid a giant lock/undo.
Return products with zero orders β the anti-join, applied.
SELECT p.*
FROM products p
LEFT JOIN order_details od ON od.product_id = p.product_id
WHERE od.order_id IS NULL;
-- equivalent, often clearer:
SELECT p.* FROM products p
WHERE NOT EXISTS (SELECT 1 FROM order_details od
WHERE od.product_id = p.product_id);
Both correct; NOT EXISTS survives NULLs and usually optimises identically. NOT IN is the version that breaks β see SQL-I-13 for why.
SQL-P-17PRACTICE
Employees with their managers β including the boss.
employees(employee_id, name, manager_id)
List every employee alongside their manager's name (top boss shows NULL), then separately return only the employees with no manager.
-- everyone + manager (LEFT keeps the boss)
SELECT e.name AS employee, m.name AS manager
FROM employees e
LEFT JOIN employees m ON e.manager_id = m.employee_id;
-- only the top of the tree β two idioms:
SELECT name FROM employees WHERE manager_id IS NULL;
SELECT e.name
FROM employees e
LEFT JOIN employees m ON e.manager_id = m.employee_id
WHERE m.employee_id IS NULL;
Self LEFT JOIN with two aliases. INNER JOIN here silently drops the CEO β the exact mistake this question exists to catch.
Find customers with no orders in the last 6 months (including customers who never ordered). Then explain why putting the date filter in WHERE gives wrong answers.
SELECT c.customer_name
FROM customers c
LEFT JOIN orders o
ON o.customer_id = c.customer_id
AND o.order_date > DATE_SUB(CURDATE(), INTERVAL 6 MONTH)
WHERE o.order_id IS NULL;
The condition lives in the ON clause: "match only recent orders" β customers with no recent match survive with NULLs β IS NULL finds them. Move the date filter to WHERE and it deletes the NULL rows too, turning the query into "customers WITH recent orders" β the precise ON-vs-WHERE trap from SQL-B-49, now in a real question.
SQL-P-19PRACTICE
Top-selling product in each category β done correctly.
Return each category's best-selling product by quantity. (A common wrong answer groups by category while selecting product_name β many engines reject it; MySQL's legacy mode returns an arbitrary product. Write the correct version.)
WITH totals AS (
SELECT c.category_name, p.product_name,
SUM(od.quantity) AS total_sold,
DENSE_RANK() OVER (PARTITION BY c.category_name
ORDER BY SUM(od.quantity) DESC) AS rk
FROM products p
JOIN categories c ON c.category_id = p.category_id
JOIN order_details od ON od.product_id = p.product_id
GROUP BY c.category_name, p.product_name
)
SELECT category_name, product_name, total_sold
FROM totals WHERE rk = 1;
Aggregate to the (category, product) grain first, then rank within each category. Windows can sit on top of aggregates in the same SELECT. DENSE_RANK keeps ties; swap to ROW_NUMBER to force exactly one winner.
SQL-P-20PRACTICE
Counting with LEFT JOIN β include the zeros (3 queries).
Write: β products per supplier including suppliers with none Β· β‘ products per category including empty categories Β· β’ employees per department including empty departments.
SELECT s.supplier_name, COUNT(p.product_id) AS total_products
FROM suppliers s
LEFT JOIN products p ON p.supplier_id = s.supplier_id
GROUP BY s.supplier_name;
SELECT c.category_name, COUNT(p.product_id) AS product_count
FROM categories c
LEFT JOIN products p ON p.category_id = c.category_id
GROUP BY c.category_name;
SELECT d.department_name, COUNT(e.employee_id) AS employee_count
FROM departments d
LEFT JOIN employees e ON e.department_id = d.department_id
GROUP BY d.department_name;
One pattern, three wearings β and the detail that makes it correct: COUNT(child.id), not COUNT(*). COUNT(*) counts the NULL-padded row and shows empty groups as 1; COUNT(column) ignores NULLs and correctly shows 0. No COALESCE needed β COUNT never returns NULL.
SQL-P-21PRACTICE
Sales performance: big sellers, and who outsells their manager.
Write: β employees whose total sold value exceeds $10,000 Β· β‘ employees whose total sales exceed their manager's. (β‘ is famously written wrong with an aggregate in WHERE β that's invalid SQL. Do it properly.)
-- β
SELECT e.name, SUM(od.quantity * p.price) AS total_sales
FROM employees e
JOIN orders o ON o.employee_id = e.employee_id
JOIN order_details od ON od.order_id = o.order_id
JOIN products p ON p.product_id = od.product_id
GROUP BY e.name
HAVING SUM(od.quantity * p.price) > 10000;
-- β‘ aggregate FIRST, then compare person to person
WITH sales AS (
SELECT employee_id, SUM(total_amount) AS total
FROM orders GROUP BY employee_id
)
SELECT e.name AS employee, se.total AS emp_sales,
m.name AS manager, sm.total AS mgr_sales
FROM employees e
JOIN employees m ON m.employee_id = e.manager_id
JOIN sales se ON se.employee_id = e.employee_id
JOIN sales sm ON sm.employee_id = m.employee_id
WHERE se.total > sm.total;
You cannot write WHERE SUM(x) > SUM(y) β aggregates don't exist yet at WHERE time. The fix is the general recipe: compute each person's aggregate in a CTE, join the CTE to itself through the manager relationship, compare plain columns.
SQL-P-22PRACTICE
Market-basket pair: similar customers & products bought together.
Write: β customers who bought any of the same products as customer 5 Β· β‘ pairs of products bought together in the same order more than 3 times.
-- β
SELECT DISTINCT c.customer_id, c.customer_name
FROM customers c
JOIN orders o ON o.customer_id = c.customer_id
JOIN order_details od ON od.order_id = o.order_id
WHERE od.product_id IN (SELECT od2.product_id
FROM order_details od2
JOIN orders o2 ON o2.order_id = od2.order_id
WHERE o2.customer_id = 5)
AND c.customer_id <> 5;
-- β‘ self-join the basket; < prevents (A,B)/(B,A) duplicates and A-with-A
SELECT od1.product_id AS product1, od2.product_id AS product2,
COUNT(*) AS times_together
FROM order_details od1
JOIN order_details od2
ON od1.order_id = od2.order_id
AND od1.product_id < od2.product_id
GROUP BY od1.product_id, od2.product_id
HAVING COUNT(*) > 3;
β‘ is the "frequently bought together" engine in one query. The < trick is the point: it halves the pairs and removes self-pairs in a single condition.
Write: β each employee's most recent order date, including employees with none Β· β‘ total quantity sold per product, showing 0 for never-sold products.
-- β
SELECT e.name, MAX(o.order_date) AS latest_order_date
FROM employees e
LEFT JOIN orders o ON o.employee_id = e.employee_id
GROUP BY e.name;
-- β‘ SUM over no rows gives NULL β COALESCE to 0
SELECT p.product_name,
COALESCE(SUM(od.quantity), 0) AS total_sold
FROM products p
LEFT JOIN order_details od ON od.product_id = p.product_id
GROUP BY p.product_name;
The pairing teaches the aggregate-NULL rules side by side: MAX over nothing β NULL (often fine to display), SUM over nothing β NULL (usually wants COALESCE to 0). COUNT is the only aggregate that gives you 0 for free.
SQL-P-24PRACTICE
"Never did X" with a condition β December orders & category gaps.
Write: β products never included in any December order Β· β‘ customers who have never bought from the 'Electronics' category. Both need the filtered-anti-join pattern.
-- β condition on the joined side goes in ON
SELECT p.product_name
FROM products p
LEFT JOIN order_details od ON od.product_id = p.product_id
LEFT JOIN orders o
ON o.order_id = od.order_id
AND MONTH(o.order_date) = 12
WHERE o.order_id IS NULL;
-- β‘ NOT EXISTS reads exactly like the requirement
SELECT c.customer_name
FROM customers c
WHERE NOT EXISTS (
SELECT 1
FROM orders o
JOIN order_details od ON od.order_id = o.order_id
JOIN products p ON p.product_id = od.product_id
WHERE o.customer_id = c.customer_id
AND p.category = 'Electronics');
"Never did X under condition Y" = anti-join where Y lives inside the join/subquery, never in the outer WHERE. For multi-hop versions like β‘, NOT EXISTS stays readable where chained LEFT JOINs get error-prone.
SQL-P-25PRACTICE
Nth highest salary (employee table).
employees(emp_id INT, name TEXT, salary DECIMAL)
Return the employee(s) with the 3rd-highest distinct salary.
SELECT name, salary
FROM (
SELECT name, salary,
DENSE_RANK() OVER (ORDER BY salary DESC) AS rk
FROM employees
) t
WHERE rk = 3;
DENSE_RANK gives the 3rd distinct salary; ties at that value all qualify. Change = 3 to any N.
SQL-P-26PRACTICE
Employees earning more than their manager.
employees(emp_id INT, name TEXT, salary DECIMAL, manager_id INT)
List employees whose salary exceeds their manager's salary.
SELECT e.name AS employee, e.salary, m.name AS manager, m.salary AS mgr_salary
FROM employees e
JOIN employees m ON e.manager_id = m.emp_id
WHERE e.salary > m.salary;
A self-join: alias the table twice and compare a row to its manager's row.
SQL-P-27PRACTICE
Duplicate emails in a users table.
users(id INT, email TEXT)
Return each email that appears more than once, with its count.
SELECT email, COUNT(*) AS n
FROM users
GROUP BY email
HAVING COUNT(*) > 1
ORDER BY n DESC;
The most-asked duplicate finder: group by the key, keep groups with count > 1.
SQL-P-28PRACTICE
Department with the highest average salary.
employees(emp_id INT, name TEXT, department TEXT, salary DECIMAL)
Return the department whose average salary is the highest.
SELECT department, AVG(salary) AS avg_salary
FROM employees
GROUP BY department
ORDER BY avg_salary DESC
LIMIT 1;
Group, average, sort descending, take the top. For ties, use RANK() instead of LIMIT.
SQL-P-29PRACTICE
Customers who never placed an order.
customers(customer_id INT, name TEXT)
orders(order_id INT, customer_id INT)
List customers with no orders at all.
SELECT c.name
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.customer_id
WHERE o.order_id IS NULL;
The anti-join: LEFT JOIN then keep only the unmatched (NULL) rows. NOT EXISTS works too.
SQL-P-30PRACTICE
Second highest salary per department.
employees(emp_id INT, department TEXT, salary DECIMAL)
Return the second-highest salary within each department.
SELECT department, salary
FROM (
SELECT department, salary,
DENSE_RANK() OVER (PARTITION BY department
ORDER BY salary DESC) AS rk
FROM employees
) t
WHERE rk = 2;
Partition by department so ranking restarts per group; filter to rank 2.
SQL-P-31PRACTICE
Running total of sales by date.
sales(sale_date DATE, amount DECIMAL)
Show each date, its amount, and the cumulative running total.
SELECT sale_date, amount,
SUM(amount) OVER (ORDER BY sale_date
ROWS UNBOUNDED PRECEDING) AS running_total
FROM sales
ORDER BY sale_date;
An ordered window with an explicit ROWS frame gives a correct running total.
SQL-P-32PRACTICE
Month-over-month revenue growth %.
revenue(month DATE, amount DECIMAL)
For each month show amount, previous month's amount, and % change.
SELECT month, amount,
LAG(amount) OVER (ORDER BY month) AS prev,
ROUND(100.0 * (amount - LAG(amount) OVER (ORDER BY month))
/ NULLIF(LAG(amount) OVER (ORDER BY month), 0), 1) AS pct_change
FROM revenue
ORDER BY month;
LAG pulls the prior row; NULLIF(...,0) prevents divide-by-zero.
Return up to the top 3 products by revenue within each category.
SELECT category, product, revenue
FROM (
SELECT category, product, revenue,
DENSE_RANK() OVER (PARTITION BY category
ORDER BY revenue DESC) AS rk
FROM sales
) t
WHERE rk <= 3
ORDER BY category, revenue DESC;
Top-N-per-group: rank within each partition, keep rank <= N.
employees(emp_id INT, name TEXT, department TEXT, salary DECIMAL)
Return the top earner (name + salary) per department.
SELECT department, name, salary
FROM (
SELECT department, name, salary,
ROW_NUMBER() OVER (PARTITION BY department
ORDER BY salary DESC) AS rn
FROM employees
) t
WHERE rn = 1;
ROW_NUMBER forces exactly one winner per department (use DENSE_RANK to keep ties).
SQL-P-38PRACTICE
Count employees per department (including empty ones).
Show each department with its employee count β zero for empty departments.
SELECT d.department_name,
COUNT(e.emp_id) AS employee_count
FROM departments d
LEFT JOIN employees e ON e.department_id = d.department_id
GROUP BY d.department_name;
Use COUNT(e.emp_id), not COUNT(*) β it correctly shows 0 for empty departments.
SQL-P-39PRACTICE
Customers who bought in Jan but not Feb.
orders(customer_id INT, order_date DATE)
Return customers with an order in January but none in February.
SELECT DISTINCT customer_id
FROM orders
WHERE order_date >= '2026-01-01' AND order_date < '2026-02-01'
AND customer_id NOT IN (
SELECT customer_id FROM orders
WHERE order_date >= '2026-02-01' AND order_date < '2026-03-01'
);
Present-in-Jan minus present-in-Feb. In production prefer NOT EXISTS for NULL safety.
SELECT order_id, customer_id, order_date
FROM (
SELECT *, ROW_NUMBER() OVER (PARTITION BY customer_id
ORDER BY order_date DESC) AS rn
FROM orders
) t
WHERE rn = 1;
Rank each customer's orders newest-first, keep rn = 1. On Snowflake use QUALIFY.
SQL-P-42PRACTICE
Products bought together more than 3 times.
order_details(order_id INT, product_id INT)
Find product pairs appearing in the same order more than 3 times.
SELECT od1.product_id AS p1, od2.product_id AS p2, COUNT(*) AS freq
FROM order_details od1
JOIN order_details od2
ON od1.order_id = od2.order_id
AND od1.product_id < od2.product_id
GROUP BY od1.product_id, od2.product_id
HAVING COUNT(*) > 3;
Self-join the basket; < makes each pair appear once and removes self-pairs.
SELECT DATE_TRUNC('month', order_date) AS month,
AVG(total_amount) AS avg_order_value
FROM orders
GROUP BY DATE_TRUNC('month', order_date)
ORDER BY month;
DATE_TRUNC gives one clean sortable month column β better than YEAR + MONTH.
SQL-P-44PRACTICE
Employees with the same salary.
employees(emp_id INT, name TEXT, salary DECIMAL)
Find salaries shared by more than one employee, and how many share each.
SELECT salary, COUNT(*) AS num_employees
FROM employees
GROUP BY salary
HAVING COUNT(*) > 1;
Same duplicate-finding pattern, applied to a value column instead of a key.
SQL-P-45PRACTICE
Rank customers by total spend.
orders(customer_id INT, total_amount DECIMAL)
Return each customer's total spend and their rank (1 = highest).
SELECT customer_id,
SUM(total_amount) AS total_spent,
RANK() OVER (ORDER BY SUM(total_amount) DESC) AS spend_rank
FROM orders
GROUP BY customer_id;
A window function can sit on top of an aggregate in the same SELECT.
SQL-P-46PRACTICE
Orders above the overall average order value.
orders(order_id INT, total_amount DECIMAL)
Return orders whose amount exceeds the average of all orders.
SELECT order_id, total_amount
FROM orders
WHERE total_amount > (SELECT AVG(total_amount) FROM orders);
A non-correlated scalar subquery computes the average once, then filters.
SQL-P-47PRACTICE
Cumulative distinct customers over time.
orders(order_date DATE, customer_id INT)
For each day, show how many distinct customers have ordered up to and including that day.
SELECT order_date,
COUNT(DISTINCT customer_id) OVER (
ORDER BY order_date ROWS UNBOUNDED PRECEDING) AS cumulative_customers
FROM orders; -- if engine disallows DISTINCT in window, pre-aggregate first-seen date
Growth metric. Where DISTINCT-in-window isn't supported, compute each customer's first order date, then running-count those.
SQL-P-48PRACTICE
Pivot monthly sales into columns.
sales(product TEXT, month TEXT, amount DECIMAL)
One row per product with columns Jan, Feb, Mar summing the amounts.
SELECT product,
SUM(CASE WHEN month='Jan' THEN amount ELSE 0 END) AS jan,
SUM(CASE WHEN month='Feb' THEN amount ELSE 0 END) AS feb,
SUM(CASE WHEN month='Mar' THEN amount ELSE 0 END) AS mar
FROM sales
GROUP BY product;
Conditional aggregation β portable pivoting that works in every dialect.
SQL-P-49PRACTICE
Consecutive login streak per user.
logins(user_id INT, login_date DATE)
Find each user's longest run of consecutive login days.
SELECT user_id, MAX(run_len) AS longest_streak
FROM (
SELECT user_id, COUNT(*) AS run_len
FROM (
SELECT user_id, login_date,
login_date - (ROW_NUMBER() OVER (PARTITION BY user_id
ORDER BY login_date)) * INTERVAL '1 day' AS grp
FROM (SELECT DISTINCT user_id, login_date FROM logins) d
) x
GROUP BY user_id, grp
) y
GROUP BY user_id;
Gaps-and-islands: date minus row-number is constant within a consecutive run, so it groups the run.
SQL-P-50PRACTICE
Percentage of total sales per category.
sales(category TEXT, amount DECIMAL)
For each category, show its sales and its share of overall sales.
SELECT category,
SUM(amount) AS category_sales,
ROUND(100.0 * SUM(amount) / SUM(SUM(amount)) OVER (), 1) AS pct_of_total
FROM sales
GROUP BY category;
The window SUM(SUM(amount)) OVER () totals all groups β share-of-total in one query.
SQL-P-51PRACTICE
Employees who joined the same month as another.
employees(emp_id INT, name TEXT, hire_date DATE)
Return employees who share their hire month-and-year with at least one other.
SELECT name, hire_date
FROM employees
WHERE DATE_TRUNC('month', hire_date) IN (
SELECT DATE_TRUNC('month', hire_date)
FROM employees
GROUP BY DATE_TRUNC('month', hire_date)
HAVING COUNT(*) > 1
);
Find the shared months first (HAVING > 1), then return everyone in them.
SQL-P-52PRACTICE
Customers with orders in every month of the year.
orders(customer_id INT, order_date DATE)
Return customers who ordered in all 12 months of 2026.
SELECT customer_id
FROM orders
WHERE order_date >= '2026-01-01' AND order_date < '2027-01-01'
GROUP BY customer_id
HAVING COUNT(DISTINCT EXTRACT(MONTH FROM order_date)) = 12;
Relational division: count distinct months per customer, require all 12.
SQL-P-53PRACTICE
Difference between each sale and the previous sale.
For each sale, show the amount and the change from the previous sale by date.
SELECT sale_date, amount,
amount - LAG(amount) OVER (ORDER BY sale_date) AS change_from_prev
FROM sales
ORDER BY sale_date;
LAG reads the previous row without a self-join β the go-to for period-over-period deltas.
SQL-P-54PRACTICE
Top spender in each city.
customers(customer_id INT, name TEXT, city TEXT)
orders(order_id INT, customer_id INT, total_amount DECIMAL)
Return the highest-spending customer in each city.
SELECT city, name, total_spent
FROM (
SELECT c.city, c.name,
SUM(o.total_amount) AS total_spent,
RANK() OVER (PARTITION BY c.city
ORDER BY SUM(o.total_amount) DESC) AS rk
FROM customers c
JOIN orders o ON o.customer_id = c.customer_id
GROUP BY c.city, c.name
) t
WHERE rk = 1;
Aggregate to (city, customer) first, then rank within each city and keep the top.
SQL Β· Professor of Data Β· this is the finished template every course follows.