Skip to content

Module 2: Advanced Analytical SQL & Window Functions ​

A Data Engineering focused reference covering ranking functions, value functions, window frames, CTEs, recursive queries, and filtering window results using dialect-agnostic ANSI SQL.


Table of Contents ​

  1. Ranking Functions
  2. Value Functions
  3. Window Frames
  4. CTEs & Modularity
  5. Filtering Window Results

1. Ranking Functions ​

ROW_NUMBER() ​

ROW_NUMBER() assigns a unique sequential integer to each row within a partition, ordered by the specified columns. Ties receive distinct numbers — the assignment among ties is non-deterministic unless the ORDER BY is fully deterministic.

sql
ROW_NUMBER() OVER (
    [PARTITION BY partition_col]
    ORDER BY sort_col [ASC | DESC]
)

Data Engineering Example

raw_events

user_idevent_typeevent_time
101page_view2024-01-15 08:00:00
101click2024-01-15 08:05:00
101purchase2024-01-15 08:30:00
102page_view2024-01-15 09:00:00
102click2024-01-15 09:10:00
sql
-- Deduplicate: keep the latest event per user
SELECT *
FROM (
    SELECT
        *,
        ROW_NUMBER() OVER (
            PARTITION BY user_id
            ORDER BY event_time DESC
        ) AS rn
    FROM raw_events
) sub
WHERE rn = 1;

Expected output:

user_idevent_typeevent_timern
101purchase2024-01-15 08:30:001
102click2024-01-15 09:10:001

WARNING

Gotcha: If two rows have the same ORDER BY values, ROW_NUMBER() assigns them arbitrarily — different runs may assign different numbers. This makes dedup non-deterministic. Always include a tiebreaker column (e.g., a unique ID) in the ORDER BY to ensure reproducible results in data pipelines.


RANK() ​

RANK() assigns a rank to each row within a partition, with gaps after ties. If two rows tie at rank 1, the next row gets rank 3 (not 2).

sql
RANK() OVER (
    [PARTITION BY partition_col]
    ORDER BY sort_col [ASC | DESC]
)

Data Engineering Example

sales

rep_idregionrevenue
R1East50000
R2East50000
R3East40000
R4West70000
R5West60000
sql
SELECT
    rep_id,
    region,
    revenue,
    RANK() OVER (PARTITION BY region ORDER BY revenue DESC) AS rnk
FROM sales;

Expected output:

rep_idregionrevenuernk
R1East500001
R2East500001
R3East400003
R4West700001
R5West600002

WARNING

Gotcha: RANK() produces gaps (1, 1, 3), which can break downstream logic that expects contiguous ranks (e.g., "get top-3" may return only 2 distinct rank values if there are ties). If you need gap-free ranks, use DENSE_RANK(). If you need exactly N rows, use ROW_NUMBER().


DENSE_RANK() ​

DENSE_RANK() assigns ranks with no gaps — ties share the same rank, and the next distinct value gets the next consecutive rank.

sql
DENSE_RANK() OVER (
    [PARTITION BY partition_col]
    ORDER BY sort_col [ASC | DESC]
)

Data Engineering Example

sql
SELECT
    rep_id,
    region,
    revenue,
    DENSE_RANK() OVER (PARTITION BY region ORDER BY revenue DESC) AS d_rank
FROM sales;

Expected output:

rep_idregionrevenued_rank
R1East500001
R2East500001
R3East400002
R4West700001
R5West600002

WARNING

Gotcha: DENSE_RANK() <= 3 returns the top-3 distinct values, which may include more than 3 rows if there are ties. If your pipeline must produce exactly 3 rows per partition, use ROW_NUMBER() <= 3 with a deterministic tiebreaker.


NTILE() ​

NTILE(n) divides the rows in each partition into n approximately equal groups (buckets), numbered 1 through n. If the rows don't divide evenly, earlier buckets get one extra row each.

sql
NTILE(n) OVER (
    [PARTITION BY partition_col]
    ORDER BY sort_col
)

Data Engineering Example

customers

customer_idlifetime_value
C15000
C212000
C38000
C41500
C520000
C63000
sql
-- Segment customers into quartiles by lifetime value
SELECT
    customer_id,
    lifetime_value,
    NTILE(4) OVER (ORDER BY lifetime_value DESC) AS quartile
FROM customers;

Expected output:

customer_idlifetime_valuequartile
C5200001
C2120001
C380002
C150002
C630003
C415004

WARNING

Gotcha: NTILE distributes rows by count, not by value range. With skewed data, bucket boundaries may be misleading — a "top quartile" might include values very close to the median. For value-based bucketing, use WIDTH_BUCKET or explicit CASE WHEN ranges. Also, with small datasets, some buckets may be empty or have only 1 row.


2. Value Functions ​

LEAD() and LAG() ​

LAG() accesses a value from a previous row; LEAD() accesses a value from a subsequent row, based on the window's ORDER BY. They are essential for calculating deltas, detecting state changes, and sessionization.

sql
LAG(column [, offset [, default]])  OVER (PARTITION BY ... ORDER BY ...)
LEAD(column [, offset [, default]]) OVER (PARTITION BY ... ORDER BY ...)

Data Engineering Example

daily_metrics

metric_datepipelinerow_count
2024-01-10etl_orders15000
2024-01-11etl_orders14200
2024-01-12etl_orders16500
2024-01-13etl_orders0
sql
SELECT
    metric_date,
    pipeline,
    row_count,
    LAG(row_count, 1, 0) OVER (
        PARTITION BY pipeline ORDER BY metric_date
    ) AS prev_day_count,
    row_count - LAG(row_count, 1, 0) OVER (
        PARTITION BY pipeline ORDER BY metric_date
    ) AS day_over_day_change
FROM daily_metrics;

Expected output:

metric_datepipelinerow_countprev_day_countday_over_day_change
2024-01-10etl_orders15000015000
2024-01-11etl_orders1420015000-800
2024-01-12etl_orders16500142002300
2024-01-13etl_orders016500-16500

WARNING

Gotcha: Without a default value (3rd argument), LAG/LEAD return NULL for boundary rows. Arithmetic on NULLs propagates NULLs, so your day-over-day change becomes NULL for the first row instead of a usable number. Always specify a default or wrap with COALESCE.


FIRST_VALUE() and LAST_VALUE() ​

FIRST_VALUE() returns the value from the first row of the window frame; LAST_VALUE() returns the value from the last row. These are useful for comparing every row against a reference point (e.g., earliest or latest value in a group).

sql
FIRST_VALUE(column) OVER (
    PARTITION BY ... ORDER BY ...
    ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
)

LAST_VALUE(column) OVER (
    PARTITION BY ... ORDER BY ...
    ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
)

Data Engineering Example

pipeline_runs

run_idpipelinestarted_atduration_sec
1etl_orders2024-01-10 02:00:00120
2etl_orders2024-01-11 02:00:0095
3etl_orders2024-01-12 02:00:00200
4etl_orders2024-01-13 02:00:00150
sql
SELECT
    run_id,
    pipeline,
    duration_sec,
    FIRST_VALUE(duration_sec) OVER (
        PARTITION BY pipeline ORDER BY started_at
        ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
    ) AS first_run_duration,
    LAST_VALUE(duration_sec) OVER (
        PARTITION BY pipeline ORDER BY started_at
        ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
    ) AS latest_run_duration
FROM pipeline_runs;

Expected output:

run_idpipelineduration_secfirst_run_durationlatest_run_duration
1etl_orders120120150
2etl_orders95120150
3etl_orders200120150
4etl_orders150120150

WARNING

Gotcha: LAST_VALUE() with the default window frame (ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) returns the current row's value, not the actual last row of the partition. You almost always need to specify ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING to get the true last value. This is the most common window function bug.


NTH_VALUE() ​

NTH_VALUE(column, n) returns the value from the n-th row of the window frame. It is useful for grabbing specific positional values (e.g., 2nd highest, 3rd event) without subqueries.

sql
NTH_VALUE(column, n) OVER (
    PARTITION BY ... ORDER BY ...
    ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
)

Data Engineering Example

sql
-- Get the 2nd fastest pipeline run duration per pipeline
SELECT
    run_id,
    pipeline,
    duration_sec,
    NTH_VALUE(duration_sec, 2) OVER (
        PARTITION BY pipeline ORDER BY duration_sec ASC
        ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
    ) AS second_fastest
FROM pipeline_runs;

Expected output:

run_idpipelineduration_secsecond_fastest
2etl_orders95120
1etl_orders120120
4etl_orders150120
3etl_orders200120

WARNING

Gotcha: If the partition has fewer than n rows, NTH_VALUE returns NULL. Also, like LAST_VALUE, the default frame (UNBOUNDED PRECEDING TO CURRENT ROW) may cause NTH_VALUE to return NULL for early rows where the n-th row hasn't been reached yet. Always specify the full frame: ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING.


3. Window Frames ​

ROWS BETWEEN vs RANGE BETWEEN ​

Window frames define the subset of rows that each window function computation operates on. ROWS counts physical rows; RANGE considers logical value ranges. Understanding the difference is critical for correct running totals, moving averages, and time-window calculations.

sql
-- ROWS: physical position-based
function() OVER (
    ORDER BY col
    ROWS BETWEEN <start> AND <end>
)

-- RANGE: logical value-based
function() OVER (
    ORDER BY col
    RANGE BETWEEN <start> AND <end>
)

-- Frame boundary options:
--   UNBOUNDED PRECEDING    (first row of partition)
--   n PRECEDING            (n rows/values before current)
--   CURRENT ROW
--   n FOLLOWING             (n rows/values after current)
--   UNBOUNDED FOLLOWING     (last row of partition)

Key Differences

AspectROWS BETWEENRANGE BETWEEN
CountingPhysical row positionsLogical value proximity
TiesEach tied row gets its own frameAll tied rows share the same frame
Default frameN/ARANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW (the default when only ORDER BY is specified)
Numeric offsetCounts rows: 3 PRECEDING = 3 rowsCounts values: 3 PRECEDING = values within ±3 of current

Data Engineering Example

daily_revenue

dayrevenue
2024-01-01100
2024-01-02200
2024-01-02150
2024-01-03300
sql
-- ROWS: running sum based on physical row position
SELECT
    day,
    revenue,
    SUM(revenue) OVER (
        ORDER BY day
        ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
    ) AS running_sum_rows
FROM daily_revenue;

Expected output:

dayrevenuerunning_sum_rows
2024-01-01100100
2024-01-02200300
2024-01-02150450
2024-01-03300750
sql
-- RANGE: running sum groups ties together
SELECT
    day,
    revenue,
    SUM(revenue) OVER (
        ORDER BY day
        RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
    ) AS running_sum_range
FROM daily_revenue;

Expected output (note: both Jan-02 rows show 450):

dayrevenuerunning_sum_range
2024-01-01100100
2024-01-02200450
2024-01-02150450
2024-01-03300750

WARNING

Gotcha: The default frame when you only specify ORDER BY (without an explicit ROWS/RANGE clause) is RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. This means ties are grouped, which silently inflates running sums. Always specify ROWS BETWEEN explicitly for predictable results.

Active Recall · Cloze Drill

Recall the explicit physical frame clause so tied timestamps increment sequentially instead of grouping:

New
SELECT day, revenue,
  SUM(revenue) OVER (
    ORDER BY day
    
  ) AS running_sum_rows
FROM daily_revenue;

Running Sums ​

A running (cumulative) sum adds each row's value to the total of all preceding rows. It is a fundamental pattern for building cumulative metrics in data warehouses.

sql
SUM(measure) OVER (
    [PARTITION BY group_col]
    ORDER BY sort_col
    ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
)

Data Engineering Example

daily_signups

signup_datechannelsignups
2024-01-01organic50
2024-01-02organic75
2024-01-03organic60
2024-01-01paid30
2024-01-02paid45
2024-01-03paid55
sql
SELECT
    signup_date,
    channel,
    signups,
    SUM(signups) OVER (
        PARTITION BY channel
        ORDER BY signup_date
        ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
    ) AS cumulative_signups
FROM daily_signups;

Expected output:

signup_datechannelsignupscumulative_signups
2024-01-01organic5050
2024-01-02organic75125
2024-01-03organic60185
2024-01-01paid3030
2024-01-02paid4575
2024-01-03paid55130

WARNING

Gotcha: Running sums with RANGE (the default) and duplicate ORDER BY values produce identical cumulative values for tied rows — which may be incorrect if each row represents a distinct transaction. Always use ROWS for running sums on transactional data.


Moving Averages ​

A moving (rolling) average smooths noisy time-series data by averaging over a fixed-size window of recent rows. It is commonly used for trend detection in pipeline monitoring dashboards.

sql
AVG(measure) OVER (
    [PARTITION BY group_col]
    ORDER BY sort_col
    ROWS BETWEEN n PRECEDING AND CURRENT ROW
)

Data Engineering Example

daily_latency

report_datepipelineavg_latency_ms
2024-01-01etl_orders120
2024-01-02etl_orders150
2024-01-03etl_orders130
2024-01-04etl_orders500
2024-01-05etl_orders140
sql
-- 3-day moving average to smooth out spikes
SELECT
    report_date,
    pipeline,
    avg_latency_ms,
    ROUND(AVG(avg_latency_ms) OVER (
        PARTITION BY pipeline
        ORDER BY report_date
        ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
    ), 1) AS moving_avg_3d
FROM daily_latency;

Expected output:

report_datepipelineavg_latency_msmoving_avg_3d
2024-01-01etl_orders120120.0
2024-01-02etl_orders150135.0
2024-01-03etl_orders130133.3
2024-01-04etl_orders500260.0
2024-01-05etl_orders140256.7

WARNING

Gotcha: For the first n-1 rows, the window contains fewer rows than specified (a "partial window"). The average is still computed but over fewer data points, which can be misleading. Consider filtering these partial-window rows out, or use COUNT(*) OVER (...) alongside to flag them.


Sliding Time Windows ​

For time-based windows (e.g., "sum of revenue in the last 7 days"), RANGE with an interval offset is more correct than ROWS because ROWS counts physical rows and misses gaps in the time series.

sql
-- Engines that support interval-based RANGE (PostgreSQL, Snowflake, etc.)
SUM(measure) OVER (
    ORDER BY date_col
    RANGE BETWEEN INTERVAL '7 days' PRECEDING AND CURRENT ROW
)

Data Engineering Example

transactions

txn_dateamount
2024-01-01100
2024-01-03200
2024-01-08150
2024-01-09300
sql
-- 7-day rolling revenue (handles date gaps correctly)
SELECT
    txn_date,
    amount,
    SUM(amount) OVER (
        ORDER BY txn_date
        RANGE BETWEEN INTERVAL '7 days' PRECEDING AND CURRENT ROW
    ) AS rolling_7d_revenue
FROM transactions;

Expected output:

txn_dateamountrolling_7d_revenue
2024-01-01100100
2024-01-03200300
2024-01-08150450
2024-01-09300650

WARNING

Gotcha: Not all engines support RANGE with interval offsets — MySQL and older SQL Server versions do not. As a workaround, join against a date spine or use a correlated subquery. Also, RANGE with intervals requires the ORDER BY column to be a date/timestamp type — numeric columns won't work with INTERVAL.


4. CTEs & Modularity ​

Common Table Expressions (CTEs) ​

A CTE (Common Table Expression) is a named temporary result set defined within a WITH clause. It exists only for the duration of the query and improves readability by breaking complex logic into named, composable steps — analogous to variables in code.

sql
WITH cte_name AS (
    SELECT ...
    FROM ...
    WHERE ...
)
SELECT *
FROM cte_name;

Data Engineering Example

raw_events

user_idevent_typeevent_time
101click2024-01-15 08:00:00
101purchase2024-01-15 08:30:00
102page_view2024-01-15 09:00:00
103clickNULL
101click2024-01-16 10:00:00
sql
-- Step-by-step transformation pipeline in a single query
WITH raw AS (
    SELECT
        user_id,
        event_type,
        CAST(event_time AS TIMESTAMP) AS event_ts
    FROM raw_events
    WHERE event_time IS NOT NULL
),

daily_agg AS (
    SELECT
        user_id,
        CAST(event_ts AS DATE) AS event_date,
        COUNT(*) AS event_count
    FROM raw
    GROUP BY user_id, CAST(event_ts AS DATE)
)

SELECT
    event_date,
    COUNT(DISTINCT user_id) AS dau,
    SUM(event_count) AS total_events
FROM daily_agg
GROUP BY event_date
ORDER BY event_date;

Expected output:

event_datedautotal_events
2024-01-1523
2024-01-1611

WARNING

Gotcha: CTEs are not materialized in most engines — they are inlined like views, so a CTE referenced multiple times may be executed multiple times. If performance matters, consider creating a temp table instead. Snowflake and BigQuery do auto-materialize in some cases, but don't rely on it.


Chaining CTEs ​

Multiple CTEs can be chained in a single WITH clause, separated by commas. Each CTE can reference any CTE defined before it, enabling pipeline-style transformations within a single statement.

sql
WITH step_1 AS (
    SELECT ... FROM source
),
step_2 AS (
    SELECT ... FROM step_1 WHERE ...
),
step_3 AS (
    SELECT ... FROM step_2 JOIN other_table ON ...
)
SELECT * FROM step_3;

Data Engineering Example

raw_orders

order_idcustomer_idamountorder_date
1001501600.002024-01-10
1002501650.002024-01-10
1003501250.002024-01-12
1004502400.002024-01-11
NULL502900.002024-01-12
sql
WITH stg_orders AS (
    -- Stage 1: Clean and type raw data
    SELECT
        CAST(order_id AS BIGINT) AS order_id,
        CAST(customer_id AS BIGINT) AS customer_id,
        CAST(amount AS DECIMAL(12,2)) AS amount,
        CAST(order_date AS DATE) AS order_date
    FROM raw_orders
    WHERE order_id IS NOT NULL
),

order_daily AS (
    -- Stage 2: Aggregate to daily grain
    SELECT
        customer_id,
        order_date,
        SUM(amount) AS daily_spend,
        COUNT(*) AS order_count
    FROM stg_orders
    GROUP BY customer_id, order_date
),

customer_summary AS (
    -- Stage 3: Roll up to customer level
    SELECT
        customer_id,
        SUM(daily_spend) AS total_spend,
        SUM(order_count) AS total_orders,
        MIN(order_date) AS first_order,
        MAX(order_date) AS last_order
    FROM order_daily
    GROUP BY customer_id
)

SELECT *
FROM customer_summary
WHERE total_spend > 1000
ORDER BY total_spend DESC;

Expected output:

customer_idtotal_spendtotal_ordersfirst_orderlast_order
5011500.0032024-01-102024-01-12

WARNING

Gotcha: Chained CTEs can become hard to debug because intermediate results are not visible. Add SELECT * FROM step_n at the end temporarily during development to inspect each stage. In dbt, prefer separate models over long CTE chains for better testability and lineage.


Recursive CTEs ​

A recursive CTE references itself to iteratively build a result set. It is the standard SQL way to traverse hierarchies (org charts, category trees, bill of materials) and graphs (paths, dependencies).

sql
WITH RECURSIVE cte_name AS (
    -- Anchor member: the starting point
    SELECT columns
    FROM base_table
    WHERE start_condition

    UNION ALL

    -- Recursive member: references the CTE itself
    SELECT columns
    FROM base_table AS t
    INNER JOIN cte_name AS c
        ON t.parent_key = c.child_key
)
SELECT * FROM cte_name;

NOTE

Some engines (SQL Server, Snowflake) omit the RECURSIVE keyword — the CTE is implicitly recursive when it references itself.

Data Engineering Example — Org Chart Traversal

employees

emp_idnamemanager_id
1AliceNULL
2Bob1
3Charlie1
4Diana2
5Eve2
6Frank3
sql
WITH RECURSIVE org_tree AS (
    -- Anchor: start from the CEO (no manager)
    SELECT
        emp_id,
        name,
        manager_id,
        0 AS depth,
        CAST(name AS VARCHAR(500)) AS path
    FROM employees
    WHERE manager_id IS NULL

    UNION ALL

    -- Recursive: find direct reports of current level
    SELECT
        e.emp_id,
        e.name,
        e.manager_id,
        ot.depth + 1,
        CAST(ot.path || ' > ' || e.name AS VARCHAR(500))
    FROM employees AS e
    INNER JOIN org_tree AS ot
        ON e.manager_id = ot.emp_id
)
SELECT emp_id, name, depth, path
FROM org_tree
ORDER BY path;

Expected output:

emp_idnamedepthpath
1Alice0Alice
2Bob1Alice > Bob
4Diana2Alice > Bob > Diana
5Eve2Alice > Bob > Eve
3Charlie1Alice > Charlie
6Frank2Alice > Charlie > Frank

WARNING

Gotcha: Recursive CTEs can enter infinite loops if the data contains cycles (e.g., A → B → A). Always add a safety limit: WHERE depth < 100 in the recursive member, or use engine-specific guards like MAXRECURSION (SQL Server) or max_recursion (Snowflake). Also, recursive CTEs can be very slow on large graphs — consider materializing adjacency lists or using a closure table pattern for production.


Example — Graph Traversal (Data Lineage)

data_lineage

source_tabletarget_table
raw_eventsstg_events
stg_eventsfct_sessions
raw_usersstg_users
stg_usersfct_sessions
fct_sessionsrpt_daily
sql
-- Find all upstream dependencies of rpt_daily
WITH RECURSIVE upstream AS (
    SELECT source_table, target_table, 1 AS hops
    FROM data_lineage
    WHERE target_table = 'rpt_daily'

    UNION ALL

    SELECT dl.source_table, dl.target_table, u.hops + 1
    FROM data_lineage AS dl
    INNER JOIN upstream AS u
        ON dl.target_table = u.source_table
    WHERE u.hops < 10  -- safety limit
)
SELECT DISTINCT source_table, hops
FROM upstream
ORDER BY hops;

Expected output:

source_tablehops
fct_sessions1
stg_events2
stg_users2
raw_events3
raw_users3

WARNING

Gotcha: The DISTINCT in the final SELECT is important — without it, the same table may appear multiple times via different paths. In production data lineage systems, prefer a dedicated graph database or a pre-materialized closure table over recursive CTEs for performance.


5. Filtering Window Results ​

Subquery Approach ​

Window functions cannot be used in WHERE or HAVING clauses because they are evaluated after WHERE and GROUP BY. To filter on a window function result, wrap the windowed query in a subquery (or CTE) and filter in the outer query.

sql
SELECT *
FROM (
    SELECT
        columns,
        WINDOW_FUNC() OVER (...) AS wf_result
    FROM table_name
) sub
WHERE wf_result = desired_value;

Data Engineering Example

raw_events

user_idevent_typeevent_time
101page_view2024-01-15 08:00:00
101purchase2024-01-15 08:30:00
102page_view2024-01-15 09:00:00
102click2024-01-15 09:10:00
sql
-- Get the most recent event per user
WITH ranked AS (
    SELECT
        user_id,
        event_type,
        event_time,
        ROW_NUMBER() OVER (
            PARTITION BY user_id
            ORDER BY event_time DESC
        ) AS rn
    FROM raw_events
)
SELECT user_id, event_type, event_time
FROM ranked
WHERE rn = 1;

Expected output:

user_idevent_typeevent_time
101purchase2024-01-15 08:30:00
102click2024-01-15 09:10:00

WARNING

Gotcha: The subquery/CTE approach adds a layer of nesting. Some engines do not push predicates into the subquery, meaning the window function is computed for all rows before filtering. On very large tables, this can be expensive. Check your query plan and consider pre-filtering in the inner query's WHERE clause.


QUALIFY ​

QUALIFY is a dedicated clause for filtering on window function results without needing a subquery. It is evaluated after SELECT and window functions, making queries cleaner and more concise.

sql
SELECT columns,
       WINDOW_FUNC() OVER (...) AS wf_result
FROM table_name
QUALIFY wf_result = desired_value;

Data Engineering Example

Source table: raw_events (same as above)

sql
-- Same dedup, but cleaner with QUALIFY
SELECT
    user_id,
    event_type,
    event_time
FROM raw_events
QUALIFY ROW_NUMBER() OVER (
    PARTITION BY user_id
    ORDER BY event_time DESC
) = 1;

Expected output:

user_idevent_typeevent_time
101purchase2024-01-15 08:30:00
102click2024-01-15 09:10:00

WARNING

Gotcha: QUALIFY is not ANSI SQL. It is supported by Snowflake, BigQuery, Databricks, and DuckDB — but not by PostgreSQL, MySQL, SQL Server, or Redshift. For portable SQL, stick with the subquery/CTE approach. Always document which dialect a query targets if your team uses QUALIFY.


SQL Logical Order of Operations (Reference) ​

Understanding the logical evaluation order is essential for knowing which clause can reference what:

1. FROM / JOIN        ← tables and join conditions
2. WHERE              ← row-level pre-filter
3. GROUP BY           ← grouping
4. HAVING             ← aggregate filter
5. SELECT             ← column expressions & window functions
6. QUALIFY            ← window function filter (non-ANSI)
7. DISTINCT           ← deduplication
8. ORDER BY           ← sorting
9. LIMIT / OFFSET     ← pagination

WARNING

Gotcha: Because SELECT aliases are created at step 5, they cannot be used in WHERE (step 2) or HAVING (step 4) in strict ANSI SQL. They can be used in QUALIFY (step 6) and ORDER BY (step 8). Some engines relax this rule — know your engine's behavior.


End of Module 2