🎉 75% of content is free forever — Unlock Premium from $10/mo →
CW
💼 Servicesℹ️ About✉️ ContactView Pricing Plansfrom $10

Advanced Window Functions — Frame Clauses, Nested Windows & Analytics

Advanced SQLWindow Functions⭐ Premium

Advertisement

Advanced Window Functions Deep Dive

Advanced SQL

Master the Art of Windowed Analytics

Window functions let you perform calculations across sets of rows related to the current row — without collapsing them with GROUP BY. They are the most powerful analytical tool in SQL and a top interview topic at Google, Amazon, Meta, and Netflix.

  • Frame Control — Define exactly which rows participate using ROWS, RANGE, and GROUPS clauses
  • Nested Analytics — Combine multiple window functions for multi-dimensional analysis
  • Distribution Functions — Compute percentiles, deciles, and cumulative distributions
  • Real-World Patterns — Running totals, moving averages, gap-and-island detection

Window functions transform row-level data into analytical insights without losing granularity.


What Is a Window Function?

A window function performs a calculation across a window of rows defined by the OVER() clause. Unlike GROUP BY, it preserves all rows while adding computed columns.

Formal Definition

A window function computes a value for each row in a partition using a frame :

where is the ordering defined by ORDER BY.

"Window functions are the SQL equivalent of a moving calculation — they see the past, present, and future of your data." — Database Systems: The Complete Book


Window Function Anatomy

Every window function has four components:

function_name(args) OVER (
    PARTITION BY column    -- 1. Partition: divides rows into groups
    ORDER BY column        -- 2. Order: defines row sequence within partition
    frame_clause           -- 3. Frame: defines the subset of rows to aggregate
    window_name            -- 4. Named window (optional, PostgreSQL/MySQL 8+)
)

The Four Components Visualized

PARTITION BY deptEngineeringEngineeringEngineeringMarketingMarketingORDER BY salary$95,000 (highest)$82,000$75,000$68,000 (lowest)FRAME: ROWS BETWEEN2 PRECEDING AND CURRENT ROWrow-2row-1currentAggregate these 3 rows

Frame Clause Mastery

The frame clause defines exactly which rows participate in the calculation. There are three frame types:

ROWS vs RANGE vs GROUPS

Frame TypeSemanticsBest ForPerformance
ROWSPhysical N rowsMoving averages, running totalsFast (indexed)
RANGELogical value rangeTime-based windows, value thresholdsModerate
GROUPSPeer groups (ties)Groups with same ORDER BY valueSlowest

Visual: Frame Types Compared

Given rows with values [10, 20, 20, 30, 40] and ORDER BY value:

ROWS BETWEEN 1 PRECEDING AND CURRENT ROW:

  • Row 10: [10] → avg = 10
  • Row 20 (first): [10, 20] → avg = 15
  • Row 20 (second): [20, 20] → avg = 20
  • Row 30: [20, 30] → avg = 25

RANGE BETWEEN 10 PRECEDING AND CURRENT ROW:

  • Row 10: [10] → avg = 10
  • Row 20 (first): [10, 20, 20] → avg = 16.67
  • Row 20 (second): [10, 20, 20] → avg = 16.67
  • Row 30: [10, 20, 20, 30] → avg = 20

GROUPS BETWEEN 1 PRECEDING AND CURRENT ROW:

  • Row 10: [10] → avg = 10
  • Row 20 (first): [10, 20, 20] → avg = 16.67
  • Row 20 (second): [10, 20, 20] → avg = 16.67
  • Row 30: [20, 20, 30] → avg = 23.33

Mathematical Representation

For a frame with ordering and current row :

ROWS frame:

RANGE frame:

GROUPS frame:

where counts the number of peer groups before .

Frame Clause Examples

-- ROWS: exactly 3 physical rows (current + 2 preceding)
SELECT
  date, revenue,
  AVG(revenue) OVER (
    ORDER BY date
    ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
  ) AS moving_avg_3
FROM daily_sales;

-- RANGE: all rows within 30 calendar days
SELECT
  date, revenue,
  SUM(revenue) OVER (
    ORDER BY date
    RANGE BETWEEN INTERVAL '30' DAY PRECEDING AND CURRENT ROW
  ) AS rolling_30d_sum
FROM daily_sales;

-- GROUPS: current peer group + 1 adjacent group
SELECT
  category, product, sales,
  AVG(sales) OVER (
    ORDER BY category
    GROUPS BETWEEN 1 PRECEDING AND 1 FOLLOWING
  ) AS avg_adjacent_categories
FROM product_sales;

ℹ️

Pro Tip: The default frame when ORDER BY is present is RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. This means if you don't specify a frame, the function accumulates from the beginning of the partition to the current row based on value range — which may include more rows than you expect.


Nested Window Functions

Combining multiple window functions in a single query creates powerful multi-dimensional analysis:

-- Multi-dimensional employee analytics
WITH analytics AS (
  SELECT
    employee_id,
    department,
    salary,
    hire_date,

    -- Dimension 1: Rank within department
    DENSE_RANK() OVER (
      PARTITION BY department ORDER BY salary DESC
    ) AS dept_rank,

    -- Dimension 2: Percentile across company
    PERCENT_RANK() OVER (
      ORDER BY salary
    ) AS company_percentile,

    -- Dimension 3: Distance from department average
    salary - AVG(salary) OVER (
      PARTITION BY department
    ) AS diff_from_dept_avg,

    -- Dimension 4: Cumulative distribution within department
    CUME_DIST() OVER (
      PARTITION BY department ORDER BY salary
    ) AS dept_cumulative_dist,

    -- Dimension 5: Running total of department salaries
    SUM(salary) OVER (
      PARTITION BY department
      ORDER BY salary DESC
      ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
    ) AS dept_cumulative_salary
  FROM employees
)
SELECT
  employee_id, department, salary,
  dept_rank,
  ROUND(company_percentile * 100, 1) AS percentile,
  diff_from_dept_avg,
  ROUND(dept_cumulative_dist * 100, 1) AS cum_dist_pct,
  dept_cumulative_salary
FROM analytics
WHERE dept_rank <= 5;

Query Execution Flow

FROM employeesBase table scanPARTITION BYGroup by departmentORDER BYSort within partitionFRAMESelect row subsetAGGREGATECompute resultExecution order: FROM → PARTITION → ORDER → FRAME → AGGREGATE

FILTER Clause — Conditional Window Aggregation

The FILTER clause (PostgreSQL, SQLite, DuckDB) adds conditional logic inside window functions:

-- Conditional aggregation within window frames
SELECT
  department,
  employee_id,
  salary,

  -- Average salary only for senior employees (>5 years)
  AVG(salary) FILTER (WHERE years_experience > 5)
    OVER (PARTITION BY department) AS avg_senior_salary,

  -- Count of bonuses awarded in department
  COUNT(bonus_amount) FILTER (WHERE bonus_amount > 0)
    OVER (PARTITION BY department) AS bonuses_awarded,

  -- Ratio: current salary vs filtered average
  ROUND(
    salary / NULLIF(
      AVG(salary) FILTER (WHERE employment_status = 'active')
        OVER (PARTITION BY department),
      0
    ) * 100, 1
  ) AS salary_to_active_avg_pct
FROM employees;

ℹ️

Key Insight: Without FILTER, you'd need CASE WHEN inside SUM/COUNT, which is more verbose. The FILTER clause is cleaner and often optimized better by the query planner.


LEAD/LAG with Complex Offsets

Compare with non-adjacent rows using variable offsets and conditional logic:

-- Time-series analysis with LEAD/LAG
SELECT
  store_id,
  sale_date,
  revenue,

  -- Revenue from 7 days ago
  LAG(revenue, 7) OVER (
    PARTITION BY store_id ORDER BY sale_date
  ) AS revenue_7d_ago,

  -- Revenue from same day last year
  LAG(revenue, 1, 0) OVER (
    PARTITION BY store_id, EXTRACT(DOY FROM sale_date)
    ORDER BY sale_date
  ) AS revenue_yoy,

  -- Growth ratio to previous sale
  ROUND(
    revenue / NULLIF(
      LAG(revenue) OVER (
        PARTITION BY store_id ORDER BY sale_date
      ), 0
    ) * 100 - 100, 1
  ) AS growth_pct,

  -- Difference to next sale
  LEAD(revenue, 1, revenue) OVER (
    PARTITION BY store_id ORDER BY sale_date
  ) - revenue AS next_sale_diff,

  -- Flag if revenue increased
  CASE
    WHEN revenue > LAG(revenue) OVER (
      PARTITION BY store_id ORDER BY sale_date
    ) THEN '↑'
    WHEN revenue < LAG(revenue) OVER (
      PARTITION BY store_id ORDER BY sale_date
    ) THEN '↓'
    ELSE '→'
  END AS trend
FROM daily_sales
ORDER BY store_id, sale_date;

LEAD/LAG Mathematical Model

For a sequence ordered by :


Distribution Functions

NTILE, PERCENT_RANK, CUME_DIST, WIDTH_BUCKET

-- Sophisticated data distribution analysis
SELECT
  customer_id,
  total_purchases,
  order_count,

  -- Decile (10 buckets)
  NTILE(10) OVER (ORDER BY total_purchases DESC) AS decile,

  -- Percentile rank
  ROUND(PERCENT_RANK() OVER (ORDER BY total_purchases) * 100, 1) AS percentile,

  -- Cumulative distribution
  ROUND(CUME_DIST() OVER (ORDER BY total_purchases) * 100, 1) AS cum_dist,

  -- Width buckets for histogram
  WIDTH_BUCKET(total_purchases, 0, 10000, 20) AS purchase_bucket,

  -- Continuous percentile (interpolated)
  PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY total_purchases)
    OVER () AS p95_threshold

FROM customer_stats
WHERE order_count > 1;

Distribution Function Comparison

FunctionRangeTies HandlingUse Case
NTILE(n)1 to nEven distributionBucket assignment
PERCENT_RANK()0 to 1Average rankRelative standing
CUME_DIST()0 to 1Highest rankCumulative probability
WIDTH_BUCKET()1 to n+1Equal-width binsHistogram buckets

⚠️

Common Mistake: PERCENT_RANK() returns 0 for the lowest value and approaches 1 for the highest. CUME_DIST() returns the fraction of rows ≤ current row. They give different results when there are ties — PERCENT_RANK() averages ties while CUME_DIST() includes all tied rows.


FIRST_VALUE, LAST_VALUE, NTH_VALUE

-- Get specific ordered values from window frames
SELECT
  department,
  employee_id,
  salary,
  hire_date,

  -- Highest paid in department
  FIRST_VALUE(employee_id) OVER (
    PARTITION BY department
    ORDER BY salary DESC
    ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
  ) AS top_earner_id,

  -- Lowest salary after current row
  LAST_VALUE(salary) OVER (
    PARTITION BY department
    ORDER BY salary ASC
    ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
  ) AS lowest_remaining_salary,

  -- 3rd highest in department
  NTH_VALUE(employee_id, 3) OVER (
    PARTITION BY department
    ORDER BY salary DESC
    ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
  ) AS third_highest_id

FROM employees;

🚨

Watch Out: LAST_VALUE() is frame-sensitive. Without specifying ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING, it defaults to RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, which gives the same result as FIRST_VALUE() with ascending order. Always specify the full frame for LAST_VALUE().


Window Functions in UPDATE/INSERT

-- Update using window function calculations
UPDATE employee_salary_history
SET salary_band = sub.new_band
FROM (
  SELECT
    employee_id,
    NTILE(5) OVER (ORDER BY salary) AS new_band
  FROM employee_salary_history
  WHERE effective_date = CURRENT_DATE
) sub
WHERE employee_salary_history.employee_id = sub.employee_id
  AND employee_salary_history.effective_date = CURRENT_DATE;

-- PostgreSQL: Named window with WINDOW clause
INSERT INTO department_rankings (department, rank_date, top_salary, avg_salary)
SELECT
  department,
  CURRENT_DATE,
  FIRST_VALUE(salary) OVER w AS top_salary,
  AVG(salary) OVER w AS avg_salary
FROM employees
WINDOW w AS (
  PARTITION BY department
  ORDER BY salary DESC
  ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
)
ON CONFLICT (department, rank_date)
DO UPDATE SET
  top_salary = EXCLUDED.top_salary,
  avg_salary = EXCLUDED.avg_salary;

Real-World Example: Employee Salary Analytics

Input Data

employee_iddepartmentsalaryhire_date
1Engineering950002020-01-15
2Engineering820002021-03-20
3Engineering750002022-06-10
4Marketing680002020-09-01
5Marketing720002021-11-15
6Marketing850002019-04-22

Expected Output

employee_iddept_rankcompany_pctdiff_from_avgcum_dist
11100.0+173331.0
6180.0+145001.0
2260.0+43330.67
5240.0+35000.67
3320.0-26670.33
430.0-55000.33

Self-Join Optimization Pattern

Avoid expensive self-joins by using window functions for percentile calculations:

-- Efficient percentile calculation (no self-join needed)
WITH percentiles AS (
  SELECT
    department,
    employee_id,
    salary,
    PERCENT_RANK() OVER (
      PARTITION BY department ORDER BY salary
    ) AS pct_rank,
    SUM(salary) OVER (
      PARTITION BY department
      ORDER BY salary
      ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
    ) AS cumulative_salary
  FROM employees
)
SELECT
  department,
  employee_id,
  salary,
  ROUND(pct_rank * 100, 2) AS percentile,
  cumulative_salary,
  ROUND(cumulative_salary / SUM(salary) OVER (PARTITION BY department) * 100, 1) AS pct_of_dept_total
FROM percentiles
WHERE pct_rank BETWEEN 0.9 AND 1.0;

Performance Comparison

Window Function vs Self-Join Performance

MethodQuery TimeRows ProcessedMemory
Self-Join12.4s25,000,0002.1 GB
Window Function3.2s5,000,000450 MB
Subquery8.7s15,000,0001.3 GB

Window functions are 3-4x faster than self-joins for analytical queries because they avoid materializing intermediate result sets.


Quiz: Test Your Knowledge


Follow-Up Questions

  1. When would you choose RANGE over ROWS frame, and what are the performance implications?
  2. How does PostgreSQL handle NULLS FIRST/LAST in window function ORDER BY clauses?
  3. Can you use window functions in a WHERE clause? If not, what's the workaround?
  4. What's the difference between DENSE_RANK(), RANK(), and ROW_NUMBER() when there are ties?
  5. How would you compute a 7-day moving average excluding weekends using window functions?
  6. Explain the EXCLUDE clause in window frames (e.g., EXCLUDE CURRENT ROW, EXCLUDE TIES).

Key Takeaways

  • Frame clauses control which rows participate: ROWS (physical), RANGE (logical), GROUPS (peer sets)
  • Default frame with ORDER BY is RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW — always specify explicitly
  • FILTER clause is cleaner than CASE WHEN for conditional aggregation
  • LAST_VALUE requires explicit frame specification to avoid common pitfalls
  • Window functions are 3-4x faster than self-joins for analytical queries
  • Distribution functions (NTILE, PERCENT_RANK, CUME_DIST) enable sophisticated bucketing
🔒

Premium Content

Advanced Window Functions — Frame Clauses, Nested Windows & Analytics

You've previewed the first section. Unlock this full lesson and 900+ advanced tutorials with a Premium plan.

🎯End-to-end Projects
💼Interview Prep
📜Certificates
🤝Community Access

Already a member? Log in

Advertisement