SQL (Snowflake): Aggregation and Window Functions Cheatsheet with Quiz
Thirteen SQL patterns behind data-engineering and analytics assessments, Snowflake flavour: aggregation, GROUP BY and HAVING, window functions, QUALIFY, CTEs, JSON and MERGE, with a 20-question practice quiz.
Syntax cheatsheet
13 patterns. Pick one on the map for its syntax, when to use it and the trap behind it.
Basics
SELECT, WHERE, ORDER BY
Rows in, rows out
- Syntax
SELECT order_id, customer_id, amount FROM ORDERS WHERE status = 'completed' AND amount > 100 ORDER BY amount DESC LIMIT 10; SELECT DISTINCT customer_id FROM ORDERS;- When to use it
- Every query. WHERE filters single rows before any grouping happens.
- What it does
- Logical order of evaluation: FROM, WHERE, GROUP BY, HAVING, SELECT, QUALIFY, DISTINCT, ORDER BY, LIMIT. That order explains what each clause can see: WHERE sees raw rows, HAVING sees groups, QUALIFY sees window results.
- Watch out
- An alias defined in SELECT cannot be used in WHERE (it runs earlier). NULL never equals anything: use IS NULL. LIMIT without ORDER BY returns an arbitrary set of rows.
- Example
- SELECT customer_id, amount * 1.21 AS gross FROM ORDERS WHERE amount * 1.21 > 100 -- not: WHERE gross > 100 AND status IS NOT NULL ORDER BY gross DESC;
Aggregation
COUNT, SUM, AVG, MIN, MAX
One number per group
- Syntax
SELECT COUNT(*) AS orders, COUNT(DISTINCT customer_id) AS customers, SUM(amount) AS revenue, AVG(amount) AS avg_order, MIN(amount), MAX(amount), MEDIAN(amount) FROM ORDERS WHERE status = 'completed';- When to use it
- Totals, averages, counts and extremes over a set of rows, with or without GROUP BY.
- What it does
- An aggregate collapses many rows into one value. Without GROUP BY the whole filtered table is one group and the query returns one row.
- Watch out
- COUNT(*) counts rows, COUNT(col) skips NULLs, COUNT(DISTINCT col) counts unique non-NULL values. AVG ignores NULLs, so AVG(col) is not SUM(col) / COUNT(*). MEDIAN resists outliers better than AVG.
- Example
- SELECT PERCENTILE_CONT(0.9) WITHIN GROUP (ORDER BY amount) AS p90, APPROX_COUNT_DISTINCT(customer_id) AS est_customers, APPROX_PERCENTILE(amount, 0.95) AS p95_fast FROM ORDERS;
Aggregation
GROUP BY and HAVING
Filter groups, not rows
- Syntax
SELECT customer_id, SUM(amount) AS total_spent, COUNT(*) AS orders FROM ORDERS WHERE status = 'completed' -- rows, before grouping GROUP BY customer_id HAVING SUM(amount) > 300 -- groups, after aggregation ORDER BY total_spent DESC;- When to use it
- Per-customer, per-month or per-status metrics; any "customers with more than N orders" question.
- What it does
- GROUP BY makes one output row per distinct combination of the grouping columns. HAVING filters those rows using aggregates; WHERE cannot, because it runs before the groups exist.
- Watch out
- Every non-aggregated SELECT column must appear in GROUP BY (or use GROUP BY ALL in Snowflake). GROUP BY 1, 2 refers to SELECT positions. A HAVING condition on a plain column is usually a WHERE in disguise and should move there.
- Example
- SELECT customer_id, status, SUM(amount) AS total FROM ORDERS GROUP BY ALL; -- Snowflake: every non-aggregated column SELECT customer_id, COUNT(DISTINCT product_id) AS products FROM ORDERS GROUP BY customer_id HAVING COUNT(DISTINCT product_id) > 2;
Aggregation
Conditional aggregation
CASE, IFF, COUNT_IF
- Syntax
SELECT customer_id, SUM(IFF(status = 'completed', amount, 0)) AS completed_revenue, SUM(CASE WHEN status = 'pending' THEN amount ELSE 0 END) AS pending_revenue, COUNT_IF(status = 'cancelled') AS cancelled_orders, COUNT_IF(status = 'completed') * 100.0 / NULLIF(COUNT(*), 0) AS completion_pct FROM ORDERS GROUP BY customer_id;- When to use it
- Several metrics with different filters in one pass (pivot-style columns), rates and percentages.
- What it does
- The condition selects which rows feed each aggregate, so one scan produces many filtered totals. NULLIF(x, 0) turns a zero divisor into NULL instead of an error; COALESCE replaces a NULL result.
- Watch out
- SUM(CASE … END) without ELSE 0 still works (NULLs are ignored) but COUNT(CASE …) counts non-NULL results, not rows. Integer division truncates: multiply by 100.0, not 100.
- Example
- SELECT COUNT(DISTINCT CASE WHEN status = 'completed' THEN customer_id END) AS buyers, COALESCE(SUM(amount), 0) AS revenue FROM ORDERS;
Aggregation
Date truncation and arithmetic
Group by month
- Syntax
DATE_TRUNC('MONTH', order_date) -- 2026-01-01 for any January date YEAR(order_date), MONTH(order_date), DAYNAME(order_date) DATEDIFF(DAY, first_date, last_date) DATEADD(DAY, -7, CURRENT_DATE) order_date::DATE- When to use it
- Monthly revenue, active customers per month, cohorts, "last 7 days" filters, gaps between orders.
- What it does
- DATE_TRUNC keeps the type and snaps the value down to the unit, so grouping and ordering by it are correct across years. DATEDIFF counts unit boundaries crossed; DATEADD shifts a date.
- Watch out
- GROUP BY MONTH(order_date) merges January 2025 with January 2026: use DATE_TRUNC or group by year and month. DATEDIFF(MONTH, "2026-01-31", "2026-02-01") is 1 because a month boundary was crossed.
- Example
- SELECT DATE_TRUNC('MONTH', order_date) AS month, SUM(amount) AS revenue, COUNT(DISTINCT customer_id) AS active_customers FROM ORDERS WHERE status = 'completed' GROUP BY 1 ORDER BY 1;
Advanced aggregation
ROLLUP, CUBE, GROUPING SETS
Subtotals in one query
- Syntax
GROUP BY ROLLUP(region, product) -- (region, product), (region), () GROUP BY CUBE(region, product) -- every combination, including () GROUP BY GROUPING SETS ((region), (product), ()) GROUPING(region) -- 1 on the rows where region was aggregated away- When to use it
- Reports that need detail rows plus subtotals and a grand total without UNION ALL of several queries.
- What it does
- Each grouping set produces its own aggregated rows in one result; a column aggregated away shows NULL on that row. GROUPING() tells a subtotal NULL from a real NULL.
- Watch out
- ROLLUP is hierarchical (it drops columns from the right); CUBE produces 2ⁿ sets, which explodes with many columns. Sort with GROUPING() to keep totals last.
- Example
- SELECT region, product, SUM(amount) AS revenue, GROUPING(product) AS is_subtotal FROM SALES GROUP BY ROLLUP(region, product) ORDER BY region NULLS LAST, is_subtotal, product;
Joins
JOINs and EXISTS
Combine tables
- Syntax
SELECT c.name, o.amount FROM CUSTOMERS c LEFT JOIN ORDERS o ON o.customer_id = c.customer_id; -- keep every customer SELECT c.customer_id FROM CUSTOMERS c WHERE NOT EXISTS (SELECT 1 FROM ORDERS o WHERE o.customer_id = c.customer_id);- When to use it
- INNER for matches only, LEFT to keep every row of the left table, FULL for both sides, CROSS for all combinations. EXISTS for "has at least one" and NOT EXISTS for "has none".
- What it does
- A join pairs rows that satisfy the ON condition. LEFT JOIN fills missing right-side columns with NULL, so WHERE o.order_id IS NULL finds customers without orders.
- Watch out
- A filter on the right table in WHERE turns a LEFT JOIN into an INNER JOIN: put it in ON. Joining on a non-unique key multiplies rows (fan-out), which inflates SUM and COUNT.
- Example
- -- customers with no completed order (the filter stays in ON) SELECT c.customer_id FROM CUSTOMERS c LEFT JOIN ORDERS o ON o.customer_id = c.customer_id AND o.status = 'completed' WHERE o.order_id IS NULL;
Query structure
CTEs and subqueries
Build in steps
- Syntax
WITH customer_revenue AS ( SELECT customer_id, SUM(amount) AS revenue FROM ORDERS GROUP BY customer_id ), avg_revenue AS (SELECT AVG(revenue) AS avg_rev FROM customer_revenue) SELECT c.* FROM customer_revenue c, avg_revenue a WHERE c.revenue > a.avg_rev;- When to use it
- Multi-step logic (aggregate, then compare, then rank), reuse of an intermediate result, readability in long queries.
- What it does
- A CTE names a result set for the rest of the statement; several chain with commas and later ones can reference earlier ones. A subquery in WHERE returns a value or a list to compare against.
- Watch out
- The average of customer revenue is not the average order value: aggregate per customer first, then average. A CTE referenced twice may run twice; materialise heavy ones in a temporary table.
- Example
- SELECT * FROM ORDERS WHERE amount > (SELECT AVG(amount) FROM ORDERS); -- scalar subquery
Window functions
Window functions and OVER()
Aggregate without collapsing
- Syntax
SELECT employee, department, salary, AVG(salary) OVER (PARTITION BY department) AS dept_avg, -- per group, every row kept AVG(salary) OVER () AS company_avg, -- whole table, on every row SUM(salary) OVER (PARTITION BY department ORDER BY salary) AS running FROM EMPLOYEES;- When to use it
- You need a group-level number next to each row: share of the total, difference from the department average, position inside the group. GROUP BY would collapse the rows you still need.
- What it does
- OVER() defines the window. PARTITION BY splits the rows into independent groups, ORDER BY sets the order inside each one (needed for running totals, ranks, LAG and LEAD), and an empty OVER() means the whole result. The function runs once per row over that row's window, so the row count never changes.
- Watch out
- GROUP BY returns one row per group; a window function returns every row. Without PARTITION BY the whole table is one group. Window functions are evaluated after WHERE, GROUP BY and HAVING, so they cannot appear in WHERE: filter them with QUALIFY (Snowflake) or a subquery.
- Example
- -- 5 employees in, 5 rows out (GROUP BY department would return 2) SELECT employee, department, salary, AVG(salary) OVER (PARTITION BY department) AS avg_salary FROM EMPLOYEES; -- Finance: Andrew 50000 → 40000, Brian 50000 → 40000, Charles 20000 → 40000 -- Sales: Daniel 30000 → 25000, Ethan 20000 → 25000
Window functions
ROW_NUMBER, RANK and QUALIFY
Latest row per group
- Syntax
ROW_NUMBER() OVER (PARTITION BY department ORDER BY salary DESC) -- 1, 2, 3, 4, 5 (always unique) RANK() OVER (ORDER BY salary DESC) -- 1, 1, 3, 4, 4 (gaps after ties) DENSE_RANK() OVER (ORDER BY salary DESC) -- 1, 1, 2, 3, 3 (no gaps) NTILE(4) OVER (ORDER BY salary DESC) -- bucket 1–4 (quartiles) PERCENT_RANK() OVER (ORDER BY salary DESC) -- (rank − 1) / (rows − 1) SELECT * FROM ORDERS QUALIFY ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_date DESC, order_id DESC) = 1;- When to use it
- Latest or first record per customer, top-N per group, deduplication of staging data, leaderboards, quartiles for segmentation.
- What it does
- ROW_NUMBER is always unique; RANK gives ties the same number and skips the next ones; DENSE_RANK gives ties the same number without skipping. QUALIFY filters on the window result inside the same query, the way HAVING filters aggregates, replacing a wrapping subquery.
- Watch out
- ROW_NUMBER breaks ties arbitrarily unless the ORDER BY is unique (add order_id). GROUP BY customer_id with MAX(order_date) gives the date but not the rest of that row. Outside Snowflake, wrap the query in a subquery and filter rn = 1 in the outer WHERE.
- Example
- -- top 3 customers by revenue SELECT customer_id, SUM(amount) AS revenue FROM ORDERS GROUP BY customer_id QUALIFY ROW_NUMBER() OVER (ORDER BY SUM(amount) DESC) <= 3; -- older duplicate registrations per e-mail (the latest one is rn = 1) SELECT * FROM CUSTOMERS QUALIFY ROW_NUMBER() OVER (PARTITION BY email ORDER BY created_at DESC) > 1;
Window functions
Running totals, frames, LAG and LEAD
Frames and offsets
- Syntax
SUM(amount) OVER (ORDER BY order_date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) -- running total SUM(amount) OVER (ORDER BY order_date ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) -- 3-row moving sum LAG(amount) OVER (PARTITION BY customer_id ORDER BY order_date) -- previous row (NULL on the first) LEAD(amount) OVER (PARTITION BY customer_id ORDER BY order_date) -- next row (NULL on the last) FIRST_VALUE(product_id) OVER (PARTITION BY customer_id ORDER BY order_date) -- first purchase amount * 100.0 / SUM(amount) OVER () -- share of the total- When to use it
- Cumulative revenue, moving averages, month-over-month change, percentage of total, gaps between consecutive dates, lifetime spending per customer.
- What it does
- The frame (ROWS BETWEEN start AND end) picks which rows of the ordered partition feed each row's aggregate: UNBOUNDED PRECEDING = from the first row, n PRECEDING / n FOLLOWING = n rows before or after, CURRENT ROW, UNBOUNDED FOLLOWING = to the last row. ROWS counts physical rows; RANGE groups rows with equal ORDER BY values. LAG and LEAD read a row at an offset without a self-join.
- Watch out
- ORDER BY without an explicit frame defaults to RANGE … CURRENT ROW, so rows with the same date are summed together: write the frame. 2 PRECEDING is three rows, not three days (join a calendar first if days are missing). LAG on the first row is NULL: wrap divisions in NULLIF. LAST_VALUE needs … AND UNBOUNDED FOLLOWING or it returns the current row.
- Example
- -- amounts 100, 200, 150, 300 → running total 100, 300, 450, 750 WITH m AS (SELECT DATE_TRUNC('MONTH', order_date) AS month, SUM(amount) AS revenue FROM ORDERS GROUP BY 1) SELECT month, revenue, LAG(revenue) OVER (ORDER BY month) AS previous_month, ROUND((revenue - LAG(revenue) OVER (ORDER BY month)) / NULLIF(LAG(revenue) OVER (ORDER BY month), 0) * 100, 2) AS growth_pct FROM m;
Snowflake
VARIANT and FLATTEN
JSON to rows
- Syntax
SELECT data:customer_id::NUMBER AS customer_id, f.value:id::NUMBER AS product_id, f.value:price::NUMBER AS price FROM RAW_ORDERS, LATERAL FLATTEN(INPUT => data:products) f; PARSE_JSON(raw_text) -- string → VARIANT ARRAY_AGG(DISTINCT product_id) -- rows → array LISTAGG(product_id, ', ') WITHIN GROUP (ORDER BY product_id) -- rows → string- When to use it
- Semi-structured sources (events, API payloads) loaded as VARIANT; building arrays, objects or strings per group for output.
- What it does
- data:field reads a key and :: casts it. FLATTEN turns each array element into a row joined to its parent (LATERAL), so ordinary aggregates and joins apply. OBJECT_AGG and ARRAY_AGG go the other way.
- Watch out
- Keys in the path are case-sensitive. A missing key returns NULL, not an error: COALESCE it. FLATTEN on an empty array yields no rows unless OUTER => TRUE; RECURSIVE => TRUE walks nested levels.
- Example
- SELECT data:customer_id::NUMBER AS customer_id, SUM(f.value:price::NUMBER) AS total FROM RAW_ORDERS, LATERAL FLATTEN(INPUT => data:products) f GROUP BY 1;
Snowflake
MERGE, PIVOT, GENERATOR
Upsert and reshape
- Syntax
MERGE INTO ORDERS t USING (SELECT * FROM STAGING_ORDERS QUALIFY ROW_NUMBER() OVER (PARTITION BY order_id ORDER BY updated_at DESC) = 1) s ON t.order_id = s.order_id WHEN MATCHED THEN UPDATE SET t.amount = s.amount, t.status = s.status WHEN NOT MATCHED THEN INSERT (order_id, amount, status) VALUES (s.order_id, s.amount, s.status); SELECT * FROM (SELECT customer_id, status, amount FROM ORDERS) PIVOT (SUM(amount) FOR status IN ('completed', 'pending', 'cancelled')); SELECT DATEADD(DAY, SEQ4(), '2026-01-01'::DATE) AS day FROM TABLE(GENERATOR(ROWCOUNT => 365));- When to use it
- Incremental loads (upsert with dedupe), one column per status, calendar tables to zero-fill missing days, sampling a large table before a full run.
- What it does
- MERGE matches source and target on a key and updates or inserts in one statement. PIVOT turns the values of one column into columns; UNPIVOT does the reverse. GENERATOR makes rows from nothing; SAMPLE returns a random subset.
- Watch out
- Deduplicate the source before MERGE or it fails when one target row matches several source rows. PIVOT needs the value list spelled out (or a dynamic pivot). SAMPLE BERNOULLI(10) returns about 10 % of the rows, not exactly.
- Example
- WITH calendar AS (SELECT DATEADD(DAY, SEQ4(), '2026-01-01'::DATE) AS day FROM TABLE(GENERATOR(ROWCOUNT => 365))), daily AS (SELECT order_date::DATE AS day, SUM(amount) AS revenue FROM ORDERS GROUP BY 1) SELECT c.day, COALESCE(d.revenue, 0) AS revenue FROM calendar c LEFT JOIN daily d ON d.day = c.day ORDER BY c.day;
Pick a pattern on the map to see its syntax, when to use it, what it does and what to watch out for.
Practice quiz
20 multiple-choice questions drawn at random from the 51 in this track, one per screen, graded at the end. Every question points back to a pattern on the map.
Pick an answer, move freely between the questions and finish whenever you like: the score and the explanation of every question appear at the end.
- 20 questions
- No time limit
- Graded at the end
The quiz needs JavaScript. The cheatsheet above works without it.
Practice material for Snowflake Data Engineer and Analytics Engineer assessments. Syntax follows the Snowflake SQL reference; the ORDERS (order_id, customer_id, product_id, amount, status, order_date) and EMPLOYEES tables are fictional.