DavidGadyan

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.

  1. 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;
  2. 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;
  3. 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;
  4. 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;
  5. 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;
  6. 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;
  7. 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;
  8. 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
  9. 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
  10. 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;
  11. 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;
  12. 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;
  13. 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.