A window function computes across a set of rows related to the current row, without collapsing them like GROUP BY does. Every window function uses OVER (...).
Unique sequential number, no ties — always 1,2,3,4...
SELECT name, salary, ROW_NUMBER() OVER ( ORDER BY salary DESC) AS rn FROM employees;
Ties share a rank; next rank skips (1,2,2,4).
SELECT name, salary, RANK() OVER ( ORDER BY salary DESC) AS rnk FROM employees;
Ties share a rank; next rank does not skip (1,2,2,3).
SELECT name, salary, DENSE_RANK() OVER ( ORDER BY salary DESC) AS drnk FROM employees;
Same two tied 90k salaries (highlighted) — watch how each function handles the tie differently.
Value from a previous row in the ordered window.
SELECT order_date, revenue, LAG(revenue, 1) OVER ( ORDER BY order_date) AS prev_day FROM daily_sales;
Value from a following row in the ordered window.
SELECT order_date, revenue, LEAD(revenue, 1) OVER ( ORDER BY order_date) AS next_day FROM daily_sales;
First / last value in the frame — remember to set the frame explicitly for LAST_VALUE (see below).
SELECT name, salary, FIRST_VALUE(name) OVER ( ORDER BY salary DESC) AS top_earner FROM employees;
Any aggregate (SUM, AVG, COUNT, MIN, MAX) can run as a window function by adding OVER (...) — rows stay ungrouped.
SELECT department, name, salary, AVG(salary) OVER (PARTITION BY department) AS dept_avg, SUM(salary) OVER (PARTITION BY department) AS dept_total FROM employees;
PARTITION BY groups rows (like GROUP BY, but no collapsing). ORDER BY inside OVER sets row order. The frame defines exactly which rows around the current row are included.
-- running total: every row up to and including the current one SELECT order_date, revenue, SUM(revenue) OVER ( ORDER BY order_date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) AS running_total FROM daily_sales; -- 3-row moving average: current row + 2 before it SELECT order_date, revenue, AVG(revenue) OVER ( ORDER BY order_date ROWS BETWEEN 2 PRECEDING AND CURRENT ROW ) AS moving_avg_3d FROM daily_sales;
ROWS counts physical rows. RANGE counts by value — rows with the same ORDER BY value are treated as one peer group, useful when ties should move together.| Construct | Persists? | Best for |
|---|---|---|
WITH ... AS (CTE) | No — scoped to one query | Readability, breaking a complex query into named steps, recursive queries. |
| Subquery | No — inline, nested | One-off, simple filters/lookups; gets unreadable when nested deeply. |
| View | Definition only (no data stored) | Reusable logic shared across many queries; always reflects live data. |
| Materialized View | Yes — result set stored on disk | Expensive aggregations queried often; needs manual/scheduled REFRESH. |
-- CTE: readable, named step WITH high_value AS ( SELECT customer_id, SUM(amount) AS total FROM orders GROUP BY customer_id ) SELECT * FROM high_value WHERE total > 10000; -- View: saved, reusable definition CREATE VIEW vw_high_value_customers AS SELECT customer_id, SUM(amount) AS total FROM orders GROUP BY customer_id; -- Materialized View: stores the result, needs refresh CREATE MATERIALIZED VIEW mv_daily_revenue AS SELECT order_date, SUM(amount) AS revenue FROM orders GROUP BY order_date; REFRESH MATERIALIZED VIEW mv_daily_revenue;
An index is a separate, sorted lookup structure that lets the engine find rows without scanning the whole table.
B-Tree: balanced, sorted tree. Leaf nodes (yellow) are linked, so it handles equality and range queries (BETWEEN, >, ORDER BY) in O(log n).
Hash index: key hashed to a bucket, O(1) average lookup — but only for exact-match (=); no ordering, so ranges and sorting can't use it.
| Clustered | Non-Clustered | |
|---|---|---|
| Data storage | Table rows are physically sorted in index order | Separate structure with pointers back to the row |
| How many per table | One (it is the table) | Many allowed |
| Lookup cost | Direct — data lives at the leaf | Extra hop ("bookmark lookup") to fetch the full row |
| Typical use | Primary key | Frequently filtered/sorted columns that aren't the PK |
An index on multiple columns, e.g. (last_name, first_name). Order matters — it only helps queries that filter on a left-to-right prefix of the columns.
CREATE INDEX idx_name ON employees(last_name, first_name); -- ✅ uses the index (prefix match) SELECT * FROM employees WHERE last_name = 'Shah'; -- ❌ does NOT use the index efficiently (skips the prefix) SELECT * FROM employees WHERE first_name = 'Ana';
EXPLAIN ANALYZE SELECT customer_id, SUM(amount) FROM orders WHERE order_date >= '2026-01-01' GROUP BY customer_id;
EXPLAIN shows the planned steps and estimated cost; EXPLAIN ANALYZE actually runs the query and shows real timing too. Read it bottom-up / inside-out — the innermost operations run first and feed the ones above.
Uses an index to jump straight to matching rows. Fast, cost scales with result size, not table size.
Reads every row in the table. Fine for small tables or when most rows match — but a warning sign on a large filtered query.
SELECT * — name the columns you need. Less I/O, less network, and lets index-only scans work (no need to fetch the full row).WHERE conditions before joining large tables where possible.WHERE YEAR(order_date) = 2026 can't use an index on order_date; rewrite as a range: WHERE order_date >= '2026-01-01' AND order_date < '2027-01-01'.OR conditions across different columns often prevent index use — consider rewriting as UNION of two indexed queries.A transaction is a group of operations that succeed or fail together.
All-or-nothing. If one step fails, the whole transaction rolls back.
A transaction moves the database from one valid state to another — constraints always hold.
Concurrent transactions don't see each other's half-finished work.
Once committed, changes survive — even a crash right after.
BEGIN; UPDATE accounts SET balance = balance - 100 WHERE id = 1; UPDATE accounts SET balance = balance + 100 WHERE id = 2; COMMIT; -- both updates succeed together, or neither does
A realistic multi-table query — monthly revenue per customer, their rank within each month, and a running total over time.
WITH monthly_customer_revenue AS ( SELECT c.customer_id, c.customer_name, DATE_TRUNC('month', o.order_date) AS month, SUM(oi.quantity * oi.unit_price) AS revenue FROM customers c JOIN orders o ON o.customer_id = c.customer_id JOIN order_items oi ON oi.order_id = o.order_id GROUP BY c.customer_id, c.customer_name, DATE_TRUNC('month', o.order_date) ), ranked AS ( SELECT *, RANK() OVER ( PARTITION BY month ORDER BY revenue DESC ) AS rank_in_month, SUM(revenue) OVER ( PARTITION BY customer_id ORDER BY month ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) AS running_total_revenue FROM monthly_customer_revenue ) SELECT customer_name, month, revenue, rank_in_month, running_total_revenue FROM ranked WHERE rank_in_month <= 3 ORDER BY month, rank_in_month;
Reads as: build the monthly revenue base in the first CTE → rank and accumulate in the second CTE → filter to top 3 per month in the final SELECT. Note the filter on rank_in_month happens in the outer query, since window function results can't be used in WHERE/HAVING directly.
-- find duplicate rows by a key SELECT email, COUNT(*) FROM users GROUP BY email HAVING COUNT(*) > 1; -- delete duplicates, keep the lowest id DELETE FROM users WHERE id NOT IN ( SELECT MIN(id) FROM users GROUP BY email );
SUM()/AVG() OVER (ORDER BY ... ROWS BETWEEN ...) — see Section 1's frame specs. Forgetting the frame clause means the default frame is used, which can silently give the wrong answer for LAST_VALUE.
-- gaps in RANK can skip valid "3rd place" ties SELECT *, DENSE_RANK() OVER ( PARTITION BY department ORDER BY salary DESC) AS drnk FROM employees QUALIFY drnk <= 3; -- (Snowflake/BigQuery); else wrap in a subquery
SELECT MAX(salary) FROM employees WHERE salary < (SELECT MAX(salary) FROM employees);
WHERE col != value silently drops NULL rows. Use WHERE col IS DISTINCT FROM value (Postgres) or explicit OR col IS NULL.
COUNT(*) counts all rows; COUNT(column) skips NULLs in that column — easy to misreport a metric if you don't notice the difference.