The SQL syntax that cracks 90% of interviews
A focused reference covering the 20% of SQL that surfaces in 90% of interview questions — from foundational SELECT through window functions, recursive CTEs, and the classic patterns recruiters love to ask. Analogies where they help, diagrams where they clarify, runnable code everywhere it counts.
SELECT, WHERE, ORDER BY
The skeleton of every SQL query. Master the clause execution order first — most interview confusion comes from misunderstanding it.
Think of a SQL query like filtering a spreadsheet: FROM opens the sheet, WHERE applies a filter, SELECT picks which columns to show, ORDER BY sorts, and LIMIT keeps only the first N rows.
SQL is evaluated in a different order than it's written. That's why you can't reference a column alias created in SELECT inside WHERE — WHERE runs before SELECT.
SELECT DISTINCT column1, column2 AS alias_name
FROM table_name
WHERE condition
ORDER BY column1 DESC, column2 ASC
LIMIT 10 OFFSET 20;
WHERE filters — operators you must know
| Operator | Meaning | Example |
|---|---|---|
= <> > < | Comparison | WHERE age > 25 |
AND OR NOT | Logical combine | WHERE dept='Eng' AND active=true |
BETWEEN a AND b | Inclusive range | WHERE age BETWEEN 25 AND 35 |
IN (a, b, c) | Match any value | WHERE dept IN ('Eng','Sales') |
LIKE | Pattern match (% any, _ one) | WHERE name LIKE 'A%_n' |
IS NULL / IS NOT NULL | NULL check (≠ =) | WHERE manager_id IS NULL |
-- Find active engineers over 30, ordered by hire date
SELECT id, name, hire_date, salary
FROM employees
WHERE dept = 'Engineering'
AND age > 30
AND manager_id IS NOT NULL
ORDER BY hire_date DESC, name ASC
LIMIT 20;
WHERE age = NULL always returns nothing. Use IS NULL. NULL behaves like "unknown" — it doesn't equal anything, including itself.
Combining Rows Across Tables
Joins are the most tested interview topic. Know each type's Venn diagram cold, and remember: JOINs combine rows horizontally; UNIONs combine them vertically.
Imagine two contact lists: one with names and phone numbers, another with names and emails. A JOIN merges them by matching the name. An INNER JOIN keeps only people in both lists. A LEFT JOIN keeps everyone from the first list, leaving email blank if missing.
-- INNER: only matching rows
SELECT e.name, d.name AS dept
FROM employees e
INNER JOIN departments d ON e.dept_id = d.id;
-- LEFT: every employee, dept name if it exists (else NULL)
SELECT e.name, d.name AS dept
FROM employees e
LEFT JOIN departments d ON e.dept_id = d.id;
-- RIGHT: every department, employee if exists
SELECT e.name, d.name AS dept
FROM employees e
RIGHT JOIN departments d ON e.dept_id = d.id;
-- FULL OUTER: everything from both sides
SELECT e.name, d.name AS dept
FROM employees e
FULL OUTER JOIN departments d ON e.dept_id = d.id;
-- CROSS: every employee paired with every department
SELECT e.name, d.name AS dept
FROM employees e
CROSS JOIN departments d;
-- SELF JOIN: employee and their manager from same table
SELECT e.name AS employee, m.name AS manager
FROM employees e
LEFT JOIN employees m ON e.manager_id = m.id;
"Find customers who never placed an order" → use LEFT JOIN ... WHERE right.id IS NULL or NOT EXISTS. Both work; NOT EXISTS is often faster and handles NULLs safely.
-- Orders → customers → employees (sales rep)
SELECT o.id, c.name AS customer, e.name AS sales_rep, o.amount
FROM orders o
JOIN customers c ON o.customer_id = c.id
JOIN employees e ON o.sales_rep_id = e.id
WHERE o.amount > 1000
ORDER BY o.amount DESC;
GROUP BY & HAVING
Aggregation collapses many rows into summary rows. The pattern is rigid: SELECT columns must be either grouped or aggregated.
Sorting a jar of M&Ms by color and counting each pile. GROUP BY color creates the piles. Aggregate functions like COUNT(*) count each pile. HAVING filters the piles (e.g., only piles with more than 5).
| Function | Returns | NULLs? |
|---|---|---|
COUNT(*) | All rows including NULLs | Counts NULLs |
COUNT(col) | Non-NULL values in col | Skips NULLs |
COUNT(DISTINCT col) | Unique non-NULL values | Skips NULLs |
SUM(col) | Sum of numeric values | Skips NULLs |
AVG(col) | Mean of non-NULL values | Skips NULLs |
MIN(col) / MAX(col) | Smallest / largest value | Skips NULLs |
SELECT dept, COUNT(*) AS headcount, AVG(salary) AS avg_salary
FROM employees
WHERE active = true -- filter rows BEFORE grouping
GROUP BY dept
HAVING COUNT(*) > 5 -- filter groups AFTER grouping
ORDER BY avg_salary DESC;
Selecting a non-grouped, non-aggregated column:
SELECT dept, name, COUNT(*) FROM employees GROUP BY dept — invalid in strict SQL. name isn't grouped or aggregated. Either add it to GROUP BY or wrap it in an aggregate.
-- Average salary by department AND job title, only groups with 3+ people
SELECT dept, title, AVG(salary) AS avg_sal, COUNT(*) AS n
FROM employees
GROUP BY dept, title
HAVING COUNT(*) >= 3
ORDER BY dept, avg_sal DESC;
-- Find duplicate emails
SELECT email, COUNT(*) AS occurrences
FROM users
GROUP BY email
HAVING COUNT(*) > 1;
WHERE filters rows before aggregation; HAVING filters groups after. Use HAVING with aggregate functions, WHERE with plain columns.
Queries Inside Queries
Subqueries let you build intermediate results inline. They appear in WHERE, FROM, and SELECT clauses — each with different rules.
| Type | Where | Returns | Use case |
|---|---|---|---|
| Scalar | SELECT / WHERE | 1 row, 1 col | Compare to a single value |
| Multi-row | WHERE with IN / ANY / ALL | Many rows, 1 col | Match against a list |
| Correlated | WHERE / SELECT | Re-runs per row | Per-row lookup |
| Derived table | FROM | A result set | Pre-aggregate, then filter |
| EXISTS | WHERE | True/False | Existence check (NULL-safe) |
-- 1. SCALAR subquery: employees earning more than company average
SELECT name, salary
FROM employees
WHERE salary > (SELECT AVG(salary) FROM employees);
-- 2. IN subquery: employees in departments with budget over 1M
SELECT name
FROM employees
WHERE dept_id IN (
SELECT id FROM departments WHERE budget > 1000000
);
-- 3. CORRELATED subquery: employees earning more than their dept average
SELECT e1.name, e1.salary, e1.dept
FROM employees e1
WHERE e1.salary > (
SELECT AVG(e2.salary)
FROM employees e2
WHERE e2.dept = e1.dept -- correlated to outer row
);
-- 4. DERIVED TABLE: pre-aggregate, then filter
SELECT dept, headcount
FROM (
SELECT dept, COUNT(*) AS headcount
FROM employees
GROUP BY dept
) t
WHERE headcount > 10;
-- 5. EXISTS: customers who placed at least one order
SELECT c.name
FROM customers c
WHERE EXISTS (
SELECT 1 FROM orders o
WHERE o.customer_id = c.id
);
NOT IN (subquery) breaks if the subquery returns any NULL. The whole result becomes empty because x NOT IN (NULL) is unknown, not true. Use NOT EXISTS instead — it's NULL-safe and usually faster.
The Interview Differentiator
Window functions perform calculations across rows related to the current row — without collapsing them like GROUP BY does. This is the #1 topic that separates junior from senior candidates.
Imagine a spreadsheet of students sorted by score. A window function is like adding a column that shows each student's rank — every original row stays, but you get an extra computed column based on neighboring rows. PARTITION BY is like restarting the rank counter for each class section.
FUNCTION_NAME() OVER (
PARTITION BY column1, column2 -- optional: restart per group
ORDER BY column3 -- optional: defines row order
ROWS BETWEEN ... AND ... -- optional: frame definition
) AS alias
Ranking functions — know the differences
| score | ROW_NUMBER() | RANK() | DENSE_RANK() |
|---|---|---|---|
| 100 | 1 | 1 | 1 |
| 100 | 2 | 1 | 1 |
| 90 | 3 | 3 | 2 |
| 80 | 4 | 4 | 3 |
ROW_NUMBER()— always unique, no ties.RANK()— ties get same rank, then skips (1,1,3,4).DENSE_RANK()— ties get same rank, no skips (1,1,2,3). Best for "Nth highest" problems.
-- Ranking: top 3 earners per department
SELECT dept, name, salary,
DENSE_RANK() OVER (PARTITION BY dept ORDER BY salary DESC) AS rnk
FROM employees;
-- ROW_NUMBER: assign sequential row ids per group
SELECT name, dept,
ROW_NUMBER() OVER (PARTITION BY dept ORDER BY hire_date) AS seq
FROM employees;
-- LAG / LEAD: compare row to previous/next (e.g., MoM growth)
SELECT month, revenue,
LAG(revenue) OVER (ORDER BY month) AS prev_month,
revenue - LAG(revenue) OVER (ORDER BY month) AS growth
FROM monthly_sales;
-- Running total (cumulative sum)
SELECT order_date, amount,
SUM(amount) OVER (ORDER BY order_date) AS running_total
FROM orders;
-- Average within department (no ORDER BY = whole partition as frame)
SELECT name, dept, salary,
AVG(salary) OVER (PARTITION BY dept) AS dept_avg
FROM employees;
-- NTILE: split into 4 quartiles by salary
SELECT name, salary,
NTILE(4) OVER (ORDER BY salary DESC) AS quartile
FROM employees;
-- FIRST_VALUE / LAST_VALUE in a partition
SELECT name, dept, salary,
FIRST_VALUE(name) OVER (PARTITION BY dept ORDER BY salary DESC) AS top_earner
FROM employees;
Frame clause — defining the "window"
-- ROWS: physical row count. RANGE: logical value range.
SUM(amount) OVER (
ORDER BY date
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW -- 3-row rolling sum
)
SUM(amount) OVER (
ORDER BY date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW -- running total
)
SUM(amount) OVER (
ORDER BY date
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING -- grand total per row
)
Window functions let you solve in one query what would otherwise need self-joins or multiple subqueries: running totals, ranks, top-N-per-group, gaps-islands, and time-series comparisons. They're almost always faster too.
Common Table Expressions
CTEs are named temporary result sets that make complex queries readable. They're also recursive — enabling tree/hierarchy traversals that are otherwise painful.
A CTE is like defining a helper variable in programming: WITH active_users AS (...) SELECT * FROM active_users. You build it once, name it, then reference it. Cleaner than nesting subqueries 4 levels deep.
WITH dept_stats AS (
SELECT dept, AVG(salary) AS avg_sal, COUNT(*) AS headcount
FROM employees
GROUP BY dept
),
high_earners AS (
SELECT name, dept, salary
FROM employees
WHERE salary > 100000
)
SELECT h.name, h.dept, h.salary, d.avg_sal, d.headcount
FROM high_earners h
JOIN dept_stats d ON h.dept = d.dept
ORDER BY h.salary DESC;
Recursive CTE — trees & hierarchies
Think of a recursive CTE like following an org chart downward. The anchor picks the CEO. The recursive member says: "for each person found, find their direct reports." Keep going until no new people are found.
WITH RECURSIVE org_tree AS (
-- Anchor: top-level (no manager)
SELECT id, name, manager_id, 1 AS depth,
CAST(name AS VARCHAR(1000)) AS path
FROM employees
WHERE manager_id IS NULL
UNION ALL
-- Recursive: find direct reports of previously found employees
SELECT e.id, e.name, e.manager_id, o.depth + 1,
CAST(o.path || ' > ' || e.name AS VARCHAR(1000))
FROM employees e
JOIN org_tree o ON e.manager_id = o.id
)
SELECT id, name, depth, path
FROM org_tree
ORDER BY path;
-- Generate a sequence of numbers 1..10
WITH RECURSIVE nums(n) AS (
SELECT 1
UNION ALL
SELECT n + 1 FROM nums WHERE n < 10
)
SELECT n FROM nums;
Recursive CTEs without a proper termination condition can infinite-loop. Always include a WHERE clause in the recursive member that eventually becomes false.
UNION, INTERSECT, EXCEPT
Set operations combine rows from two result sets vertically. Both queries must return the same number of columns with compatible types.
| Operator | Behavior | Duplicates? |
|---|---|---|
UNION | All rows from both | Removed |
UNION ALL | All rows from both | Kept (faster) |
INTERSECT | Rows in both | Removed |
EXCEPT / MINUS | Rows in first only | Removed |
-- UNION ALL: combine customer + employee names (keep duplicates)
SELECT name, email FROM customers
UNION ALL
SELECT name, email FROM employees;
-- INTERSECT: people who are both customers AND employees
SELECT name FROM customers
INTERSECT
SELECT name FROM employees;
-- EXCEPT: customers who are NOT employees
SELECT name FROM customers
EXCEPT
SELECT name FROM employees;
Use UNION ALL instead of UNION unless you specifically need duplicates removed. UNION triggers a sort/distinct that can be expensive on large datasets.
Conditional Logic & NULL Helpers
CASE is SQL's if/then/else. Combined with aggregates, it powers pivots and conditional counts. Plus the trio of NULL helpers you must know.
-- Searched CASE (more flexible, use this)
SELECT name, salary,
CASE
WHEN salary >= 150000 THEN 'Staff+'
WHEN salary >= 100000 THEN 'Senior'
WHEN salary >= 70000 THEN 'Mid'
ELSE 'Junior'
END AS level
FROM employees;
-- Simple CASE (compares single value for equality)
SELECT name,
CASE dept
WHEN 'Eng' THEN 'Engineering'
WHEN 'Sales' THEN 'Sales'
ELSE 'Other'
END AS dept_name
FROM employees;
CASE inside aggregates — the pivot trick
-- Count employees by dept in one row per department
SELECT
COUNT(CASE WHEN dept = 'Eng' THEN 1 END) AS eng_count,
COUNT(CASE WHEN dept = 'Sales' THEN 1 END) AS sales_count,
COUNT(CASE WHEN dept = 'Ops' THEN 1 END) AS ops_count
FROM employees;
-- Pivot: revenue per quarter, one row per dept
SELECT dept,
SUM(CASE WHEN quarter = 1 THEN revenue ELSE 0 END) AS q1,
SUM(CASE WHEN quarter = 2 THEN revenue ELSE 0 END) AS q2,
SUM(CASE WHEN quarter = 3 THEN revenue ELSE 0 END) AS q3,
SUM(CASE WHEN quarter = 4 THEN revenue ELSE 0 END) AS q4
FROM quarterly_revenue
GROUP BY dept;
NULL helpers
| Function | What it does | Example |
|---|---|---|
COALESCE(a, b, c) | First non-NULL value | COALESCE(phone, email, 'no contact') |
NULLIF(a, b) | NULL if a = b, else a | NULLIF(denominator, 0) — avoids divide-by-zero |
GREATEST(a, b, c) | Largest of values | GREATEST(q1, q2, q3, q4) |
LEAST(a, b, c) | Smallest of values | LEAST(q1, q2, q3, q4) |
-- Safe percentage: divide but never by zero
SELECT
dept,
SUM(CASE WHEN active THEN 1 ELSE 0 END) AS active_count,
COUNT(*) AS total,
ROUND(
SUM(CASE WHEN active THEN 1 ELSE 0 END) * 100.0 /
NULLIF(COUNT(*), 0),
2
) AS pct_active
FROM employees
GROUP BY dept;
String Functions
Pattern matching, concatenation, slicing. Note: function names vary by database — know both flavors.
| Operation | PostgreSQL / Standard | MySQL |
|---|---|---|
| Concatenate | CONCAT(a, b) or a || b | CONCAT(a, b) |
| Length | LENGTH(s) | CHAR_LENGTH(s) |
| Substring | SUBSTRING(s FROM 1 FOR 3) | SUBSTRING(s, 1, 3) or SUBSTR() |
| Uppercase / Lowercase | UPPER(s) / LOWER(s) | same |
| Trim | TRIM(s) / BTRIM / LTRIM / RTRIM | same |
| Replace | REPLACE(s, 'a', 'b') | same |
| Position | POSITION('x' IN s) | INSTR(s, 'x') |
| Pad | LPAD(s, n, '0') | same |
| Split | SPLIT_PART(s, ',', 2) | SUBSTRING_INDEX(s, ',', 2) |
| Group concat | STRING_AGG(s, ',' ORDER BY s) | GROUP_CONCAT(s SEPARATOR ',') |
-- Clean and normalize names
SELECT
TRIM(name) AS clean_name,
UPPER(TRIM(name)) AS upper_name,
INITCAP(name) AS proper_name -- PostgreSQL
FROM users;
-- Extract domain from email
SELECT email,
SUBSTRING(email FROM POSITION('@' IN email) + 1) AS domain
FROM users;
-- Mask all but last 4 digits of SSN
SELECT
CONCAT(REPEAT('•', LENGTH(ssn) - 4), RIGHT(ssn, 4)) AS masked
FROM users;
-- Combine rows into a comma-separated list
SELECT dept,
STRING_AGG(name, ', ' ORDER BY name) AS all_names
FROM employees
GROUP BY dept;
-- Pattern matching with regex (PostgreSQL)
SELECT name FROM users
WHERE name ~ '^[A-Z][a-z]+$'; -- starts capital, rest lowercase
Date & Time Functions
Date math shows up constantly in analytics interviews — retention, growth, time-between-events. Syntax varies a lot by dialect; learn the patterns.
| Operation | PostgreSQL | MySQL |
|---|---|---|
| Now / today | NOW() / CURRENT_DATE | NOW() / CURDATE() |
| Add interval | date + INTERVAL '7 days' | DATE_ADD(date, INTERVAL 7 DAY) |
| Difference (days) | date2 - date1 | DATEDIFF(date2, date1) |
| Extract part | EXTRACT(YEAR FROM date) | YEAR(date) |
| Truncate to month | DATE_TRUNC('month', date) | DATE_FORMAT(date, '%Y-%m-01') |
| Format | TO_CHAR(date, 'YYYY-MM-DD') | DATE_FORMAT(date, '%Y-%m-%d') |
| Cast string to date | date '2024-01-15' or CAST(s AS date) | STR_TO_DATE(s, '%Y-%m-%d') |
-- Users who signed up in the last 30 days
SELECT name, created_at
FROM users
WHERE created_at >= CURRENT_DATE - INTERVAL '30 days';
-- Orders grouped by month
SELECT
DATE_TRUNC('month', order_date) AS month,
COUNT(*) AS order_count,
SUM(amount) AS revenue
FROM orders
GROUP BY DATE_TRUNC('month', order_date)
ORDER BY month;
-- Average days between a user's first and second order
WITH order_seq AS (
SELECT user_id, order_date,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY order_date) AS n
FROM orders
)
SELECT AVG(second_date - first_date) AS avg_days_between
FROM (
SELECT user_id,
MAX(CASE WHEN n = 1 THEN order_date END) AS first_date,
MAX(CASE WHEN n = 2 THEN order_date END) AS second_date
FROM order_seq
WHERE n <= 2
GROUP BY user_id
) t
WHERE second_date IS NOT NULL;
-- Age from birthdate
SELECT name,
EXTRACT(YEAR FROM AGE(CURRENT_DATE, birthdate))::int AS age
FROM users;
INSERT, UPDATE, DELETE, UPSERT
Data Manipulation Language — modifying rows. Less common in read-heavy interviews, but you'll need UPSERT and UPDATE-FROM-JOIN patterns.
-- Single row
INSERT INTO employees (name, dept, salary)
VALUES ('Alice', 'Eng', 120000);
-- Multiple rows
INSERT INTO employees (name, dept, salary)
VALUES
('Bob', 'Eng', 95000),
('Carol', 'Sales', 85000),
('Dave', 'Eng', 110000);
-- INSERT from SELECT (load from another table)
INSERT INTO archive_employees (id, name, retired_on)
SELECT id, name, CURRENT_DATE
FROM employees
WHERE active = false;
-- UPDATE with condition
UPDATE employees
SET salary = salary * 1.1, updated_at = NOW()
WHERE dept = 'Eng' AND performance = 'exceeds';
-- UPDATE from join (PostgreSQL syntax)
UPDATE employees e
SET salary = e.salary * 1.05
FROM departments d
WHERE e.dept_id = d.id AND d.budget > 1000000;
-- DELETE with condition
DELETE FROM employees
WHERE active = false AND last_login < CURRENT_DATE - INTERVAL '1 year';
-- DELETE using subquery
DELETE FROM orders
WHERE customer_id IN (
SELECT id FROM customers WHERE deleted = true
);
-- DELETE duplicates keeping the lowest id
DELETE FROM users
WHERE id NOT IN (
SELECT MIN(id) FROM users GROUP BY email
);
UPSERT (INSERT ... ON CONFLICT)
-- Insert, or update on conflict
INSERT INTO employees (id, name, salary)
VALUES (1, 'Alice', 120000)
ON CONFLICT (id)
DO UPDATE SET salary = EXCLUDED.salary, name = EXCLUDED.name;
-- Insert, or do nothing
INSERT INTO subscriptions (user_id, plan)
VALUES (42, 'pro')
ON CONFLICT (user_id) DO NOTHING;
INSERT INTO employees (id, name, salary)
VALUES (1, 'Alice', 120000)
ON DUPLICATE KEY UPDATE salary = VALUES(salary);
Schema, Constraints, Indexes
Data Definition Language — creating and modifying tables. Interviews rarely test DML/DDL directly, but you should know constraints, indexes, and views cold.
CREATE TABLE employees (
id SERIAL PRIMARY KEY, -- auto-incrementing PK
email VARCHAR(255) NOT NULL UNIQUE, -- no duplicates
name VARCHAR(100) NOT NULL,
dept_id INTEGER REFERENCES departments(id), -- foreign key
salary NUMERIC(10, 2) CHECK (salary >= 0), -- constraint
hire_date DATE DEFAULT CURRENT_DATE, -- default value
manager_id INTEGER REFERENCES employees(id), -- self-reference
created_at TIMESTAMP DEFAULT NOW(),
-- Table-level constraints
CONSTRAINT valid_salary CHECK (salary < 1000000),
CONSTRAINT unique_dept_role UNIQUE (dept_id, email)
);
Common constraints
| Constraint | What it enforces |
|---|---|
PRIMARY KEY | Unique + NOT NULL. One per table. Auto-indexed. |
FOREIGN KEY | Value must exist in referenced table's column. Prevents orphans. |
UNIQUE | No duplicate values. NULL allowed (usually multiple). |
NOT NULL | Cannot be NULL. |
CHECK | Custom boolean condition must be true. |
DEFAULT | Value used when none provided on INSERT. |
-- Add / drop column
ALTER TABLE employees ADD COLUMN phone VARCHAR(20);
ALTER TABLE employees DROP COLUMN phone;
-- Add constraint
ALTER TABLE employees
ADD CONSTRAINT positive_salary CHECK (salary >= 0);
-- Create index (for query speed)
CREATE INDEX idx_employees_dept ON employees(dept_id);
CREATE UNIQUE INDEX idx_email ON employees(email);
CREATE INDEX idx_orders_date_amount ON orders(order_date, amount); -- composite
-- Drop & truncate (TRUNCATE is faster, can't be rolled back in some DBs)
DROP TABLE employees; -- removes structure + data
TRUNCATE TABLE employees; -- removes data, keeps structure
-- View: a saved query
CREATE VIEW active_employees AS
SELECT id, name, dept
FROM employees
WHERE active = true;
Indexes speed up WHERE, JOIN ON, and ORDER BY — but slow down writes. Index foreign keys and columns you filter/sort on. Composite indexes work left-to-right: an index on (a, b, c) helps WHERE a=1 and WHERE a=1 AND b=2, but not WHERE b=2.
The 10 Classic Interview Patterns
These show up repeatedly across LeetCode, HackerRank, and live interviews. Memorize the patterns, not the answers — most variations are remixes of these.
Three approaches, from simplest to most portable. DENSE_RANK is preferred because it handles ties correctly.
-- Approach 1: LIMIT + OFFSET (MySQL/PostgreSQL, simplest)
SELECT DISTINCT salary
FROM employees
ORDER BY salary DESC
LIMIT 1 OFFSET (N - 1); -- N=2 for 2nd highest
-- Approach 2: Subquery (works everywhere, but only for 2nd highest easily)
SELECT MAX(salary)
FROM employees
WHERE salary < (SELECT MAX(salary) FROM employees);
-- Approach 3: DENSE_RANK (modern, handles ties, scales to any N)
SELECT salary FROM (
SELECT salary,
DENSE_RANK() OVER (ORDER BY salary DESC) AS rnk
FROM employees
) t
WHERE rnk = N;
GROUP BY + HAVING is the canonical answer.
-- Duplicate emails
SELECT email, COUNT(*) AS cnt
FROM users
GROUP BY email
HAVING COUNT(*) > 1;
-- Duplicate (name, dept) pairs
SELECT name, dept, COUNT(*) AS cnt
FROM employees
GROUP BY name, dept
HAVING COUNT(*) > 1;
Use ROW_NUMBER to mark all-but-one row per group, then delete the marked rows.
-- Using CTE + ROW_NUMBER
WITH dups AS (
SELECT id,
ROW_NUMBER() OVER (PARTITION BY email ORDER BY id) AS rn
FROM users
)
DELETE FROM users
WHERE id IN (SELECT id FROM dups WHERE rn > 1);
-- Self-join approach (MySQL, older versions)
DELETE u1
FROM users u1
JOIN users u2
ON u1.email = u2.email AND u1.id > u2.id;
The classic "find numbers appearing N times in a row." Uses ROW_NUMBER difference trick.
-- Find numbers appearing 3+ times consecutively
SELECT DISTINCT num
FROM (
SELECT num,
ROW_NUMBER() OVER (ORDER BY id) -
ROW_NUMBER() OVER (PARTITION BY num ORDER BY id) AS diff
FROM logs
) t
GROUP BY num, diff
HAVING COUNT(*) >= 3;
-- Alternative: self-join (more intuitive, less scalable)
SELECT DISTINCT a.num
FROM logs a
JOIN logs b ON a.id = b.id - 1 AND a.num = b.num
JOIN logs c ON b.id = c.id - 1 AND b.num = c.num;
The "Department Top 3" problem. Use DENSE_RANK if ties should be included, ROW_NUMBER if you need exactly N rows.
SELECT dept, name, salary
FROM (
SELECT name, dept, salary,
DENSE_RANK() OVER (PARTITION BY dept ORDER BY salary DESC) AS rnk
FROM employees
) t
WHERE rnk <= 3
ORDER BY dept, salary DESC;
Pure window function application.
-- Cumulative revenue over time
SELECT
order_date,
amount,
SUM(amount) OVER (ORDER BY order_date
ROWS UNBOUNDED PRECEDING) AS running_total
FROM orders
ORDER BY order_date;
-- Per-customer running total
SELECT
customer_id, order_date, amount,
SUM(amount) OVER (PARTITION BY customer_id
ORDER BY order_date
ROWS UNBOUNDED PRECEDING) AS customer_total
FROM orders;
LAG gives you the previous period's value in the same row, making growth calculations trivial.
SELECT
year,
revenue,
LAG(revenue) OVER (ORDER BY year) AS prev_year,
ROUND(
(revenue - LAG(revenue) OVER (ORDER BY year)) * 100.0 /
NULLIF(LAG(revenue) OVER (ORDER BY year), 0),
2
) AS yoy_growth_pct
FROM yearly_revenue
ORDER BY year;
Recursive CTE for org charts, category trees, conversation threads.
WITH RECURSIVE org_chain AS (
SELECT id, name, manager_id, 1 AS level,
CAST(name AS VARCHAR(1000)) AS path
FROM employees
WHERE manager_id IS NULL -- start at the top
UNION ALL
SELECT e.id, e.name, e.manager_id, o.level + 1,
CAST(o.path || ' > ' || e.name AS VARCHAR(1000))
FROM employees e
JOIN org_chain o ON e.manager_id = o.id
)
SELECT level, name, path FROM org_chain
ORDER BY path;
No PIVOT operator in most databases — use CASE + aggregate. The universal pattern.
-- Revenue per department, one column per quarter
SELECT
dept,
SUM(CASE WHEN quarter = 1 THEN revenue ELSE 0 END) AS q1,
SUM(CASE WHEN quarter = 2 THEN revenue ELSE 0 END) AS q2,
SUM(CASE WHEN quarter = 3 THEN revenue ELSE 0 END) AS q3,
SUM(CASE WHEN quarter = 4 THEN revenue ELSE 0 END) AS q4,
SUM(revenue) AS total
FROM quarterly_revenue
GROUP BY dept
ORDER BY total DESC;
"Customers who never ordered." Use LEFT JOIN + IS NULL or NOT EXISTS.
-- LEFT JOIN approach (most intuitive)
SELECT c.name
FROM customers c
LEFT JOIN orders o ON c.id = o.customer_id
WHERE o.id IS NULL;
-- NOT EXISTS (NULL-safe, often fastest)
SELECT c.name
FROM customers c
WHERE NOT EXISTS (
SELECT 1 FROM orders o WHERE o.customer_id = c.id
);
-- NOT IN (works, but breaks if orders.customer_id has NULLs)
SELECT name FROM customers
WHERE id NOT IN (SELECT customer_id FROM orders);
Query Optimization Essentials
Senior interviews often include "how would you optimize this query?" Know these patterns cold.
Read the execution plan
-- View the query plan (PostgreSQL)
EXPLAIN SELECT * FROM employees WHERE dept_id = 5;
-- View plan + actually run it (shows real timing)
EXPLAIN ANALYZE SELECT * FROM employees WHERE dept_id = 5;
-- MySQL equivalent
EXPLAIN SELECT * FROM employees WHERE dept_id = 5;
Look for: Seq Scan (full table scan = bad on large tables), Index Scan (good), Bitmap Heap Scan (medium). Watch for nested loops with high row estimates.
Sargable predicates (Search ARGument ABLE)
These prevent index usage — the database must scan every row:
WHERE YEAR(date_col) = 2024 — function on column kills index. Use: WHERE date_col >= '2024-01-01' AND date_col < '2025-01-01'
WHERE LOWER(email) = 'alice@x.com' — use a case-insensitive collation or store lowercase.
WHERE col LIKE '%alice' — leading wildcard can't use index. LIKE 'alice%' can.
WHERE col + 1 = 10 — rewrite as col = 9.
Quick wins
- Avoid
SELECT *— fetch only needed columns. Saves I/O, network, memory. - Index foreign keys — most databases don't auto-index them; joins get slow.
- Index columns in WHERE, JOIN ON, ORDER BY — but remember indexes cost write speed.
- Use
UNION ALLoverUNIONunless you need deduplication. - Use
EXISTSoverINfor large subquery result sets — short-circuits on first match. - Limit early — add
LIMITbefore complex joins if you only need a sample. - Filter in WHERE, not HAVING — WHERE runs before aggregation, cheaper.
- Batch large deletes —
DELETE FROM logs WHERE date < ... LIMIT 10000in a loop, not one massive DELETE.
When asked "how would you optimize this query?" — walk through: (1) check the execution plan, (2) identify full table scans, (3) add indexes on filter/join columns, (4) rewrite non-sargable predicates, (5) consider denormalization or materialized views for read-heavy workloads.
No matches found. Try a different search term.