BIX Tech

Window functions in SQL: advanced patterns every analyst should know

Advanced SQL window functions every analyst should know

9 min of reading
Sabrina Oliveira
Illustration of SQL window functions: a sliding window framing rows of a data table, with LAG and LEAD arrows and a moving-average line chart

Get your project off the ground

Share

Window functions in SQL: advanced patterns every analyst should know

SQL window functions are the difference between an analyst who exports data to a spreadsheet to finish the job and one who answers the question inside the query. They let you rank rows, compute running totals, compare a value to the previous period, and calculate moving averages without collapsing your data into groups. If you already write solid queries against a modern data stack on AWS or a cloud warehouse, this is the layer that turns good SQL into analytical SQL.

Most analysts learn GROUP BY early and stop there. The problem is that GROUP BY throws away detail: it returns one row per group, so you lose the individual records you were aggregating. Window functions keep every row and add the calculation alongside it, which is exactly what you need for ranking, sequencing, and trend analysis in any serious business intelligence work.

This guide walks through the SQL window functions that show up most often in real analytics, from ranking and deduplication to period-over-period comparisons, with runnable examples you can adapt to Postgres, BigQuery, Snowflake, or Redshift. The syntax is portable across engines, which makes these patterns worth learning once and reusing everywhere across your data engineering stack.

What are SQL window functions?

A window function performs a calculation across a set of rows related to the current row, without collapsing them. That set is the "window", and you define it with the OVER clause. Unlike an aggregate with GROUP BY, a window function returns a value for every input row, which is why analysts reach for it whenever the answer depends on order or position, not just totals.

The OVER clause has three parts you will use constantly. PARTITION BY splits the data into independent groups, resetting the calculation for each one, much like GROUP BY but without merging rows. ORDER BY defines the sequence inside each partition, which matters for running totals and ranking. The frame clause (ROWS BETWEEN ...) narrows the window to a sliding range of rows, and it is the piece most people skip until a moving average forces them to learn it. If your team also models metrics downstream, aligning these calculations with your dbt semantic layer keeps definitions consistent between raw SQL and the metrics layer.

SELECT
  region,
  sales_rep,
  amount,
  SUM(amount) OVER (PARTITION BY region ORDER BY sale_date) AS running_total
FROM sales;

The same amount column stays visible on each row, and running_total accumulates within each region in date order. That single query would take a self-join or a correlated subquery without window functions, and it would be slower and harder to read, which also matters for BigQuery cost optimization when queries scan large tables.

Advanced SQL window function patterns every analyst should know

The patterns below cover the majority of analytical questions that stump people who only know GROUP BY. Each one is a small template you can drop into a query and adjust, and together they form the core toolkit behind most Power BI performance tuning and warehouse-side reporting.

Ranking and deduplication with ROW_NUMBER, RANK, and DENSE_RANK

Ranking functions assign a position to each row inside its partition. ROW_NUMBER() gives a unique sequential number, RANK() leaves gaps after ties, and DENSE_RANK() does not. The classic use is "top N per group", such as the three best-selling products in every category, which is awkward to express with plain aggregation but trivial here.

SELECT category, product, revenue
FROM (
  SELECT category, product, revenue,
         ROW_NUMBER() OVER (PARTITION BY category ORDER BY revenue DESC) AS rn
  FROM product_sales
) ranked
WHERE rn <= 3;

ROW_NUMBER() also solves deduplication cleanly. Partition by the columns that define a duplicate, order by a tiebreaker such as updated_at DESC, then keep only rn = 1. This pattern is a staple of data cleaning in any pipeline feeding a data lakehouse, where late-arriving records create duplicate keys.

Running totals and moving averages

Cumulative sums and rolling averages are where the frame clause earns its keep. A running total uses the default frame, but a moving average needs an explicit window of rows. To compute a 7-day moving average, you tell SQL to look at the current row and the six before it.

SELECT
  sale_date,
  daily_revenue,
  AVG(daily_revenue) OVER (
    ORDER BY sale_date
    ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
  ) AS moving_avg_7d
FROM daily_sales;

Moving averages smooth out noise so a dashboard shows the trend instead of the daily spikes, which is the kind of calculation that belongs in the query rather than in the embedded analytics dashboard layer. Pushing it down to SQL keeps the logic in one place and consistent across every tool that reads the table.

Period-over-period comparison with LAG and LEAD

LAG() and LEAD() reach into other rows relative to the current one, which makes month-over-month or year-over-year growth a one-liner. LAG(revenue, 1) returns the previous row's revenue in the ordered window, so you can compute the delta without a self-join.

SELECT
  month,
  revenue,
  revenue - LAG(revenue) OVER (ORDER BY month) AS mom_change,
  ROUND(100.0 * (revenue - LAG(revenue) OVER (ORDER BY month))
        / LAG(revenue) OVER (ORDER BY month), 1) AS mom_pct
FROM monthly_revenue;

The same idea drives churn and retention analysis: partition by customer, order by event date, and use LAG to measure the gap between purchases. Analysts working alongside data science teams often use this to build features before handing data to data science and AI models, since sequence-based signals are hard to reconstruct later.

Bucketing with NTILE and positional values

NTILE(n) splits rows into n roughly equal buckets, which is how you build quartiles, deciles, or percentile segments for customer scoring. FIRST_VALUE() and LAST_VALUE() pull the first or last value in a window, useful for comparing every row to the opening or closing figure of its group. These positional functions are common in cohort and RFM segmentation feeding a warehouse-native analytics workflow.

When window functions beat GROUP BY and subqueries

Choosing between a window function, a GROUP BY, and a subquery is situational, and the right call depends on what you need to keep and how the engine executes it. The table below maps common questions to the pattern that fits, so you can pick deliberately instead of defaulting to whatever you learned first, a habit that also helps when AI-assisted SQL generation proposes a query you need to review.

Analytical questionBest patternWhy it fits
One total per group, detail not neededGROUP BYCollapses rows, smallest result set
Total per group but keep every rowSUM() OVER (PARTITION BY ...)Adds the aggregate without losing detail
Top N rows within each groupROW_NUMBER() filtered in outer queryRanks per partition in a single pass
Compare a row to the previous or nextLAG() / LEAD()Avoids self-joins on the same table
Running total or moving averageSUM() / AVG() with a frame clauseSequential math the frame handles natively
Percentiles or equal-size bucketsNTILE()Assigns buckets without manual thresholds

Performance is the other half of the decision. A window function usually scans the table once and sorts within partitions, which is often cheaper than a correlated subquery that re-reads the table for every row. On columnar warehouses the difference can be large, so the pattern you choose feeds directly into cloud data and AI platform cost and speed. The official PostgreSQL window functions documentation and the BigQuery window function reference are worth keeping open, since frame defaults and supported functions vary slightly by engine.

Learning these patterns changes how you approach a dataset. Instead of exporting to a spreadsheet or stacking subqueries, you express ranking, sequencing, and trends directly in SQL, closer to the data and easier to reproduce. That fluency compounds across every report and pipeline you touch, and it is one of the clearest signals that a team has moved from basic querying to genuine analytical engineering across its BI and data solutions.

If your team is standardizing SQL practices, building a metrics layer, or trying to get more analytical depth out of your data platform, our specialists can help you design the architecture and workflows that fit your context. Talk to our team and turn advanced SQL into a repeatable advantage. ⬇️

Talk to BIX Tech specialists and turn SQL window functions into reliable, scalable analytics

What are SQL window functions used for? SQL window functions perform calculations across a set of related rows while keeping each row visible, unlike GROUP BY, which collapses rows into groups. Analysts use them for ranking, running totals, moving averages, period-over-period comparisons, and percentile bucketing, all inside a single query without self-joins or exports.

What is the difference between a window function and GROUP BY? GROUP BY returns one aggregated row per group and discards the individual records. A window function returns a value for every original row, adding the aggregate alongside the detail. Use GROUP BY when you only need totals, and a window function when you need both the total and the underlying rows.

Which SQL window functions should every analyst know first? Start with ROW_NUMBER(), RANK(), and DENSE_RANK() for ranking and deduplication, SUM() and AVG() with the OVER clause for running totals and moving averages, and LAG() and LEAD() for period-over-period comparisons. Add NTILE() for bucketing once the first four feel natural.

Do window functions work the same in Postgres, BigQuery, and Snowflake? The core syntax is portable: OVER, PARTITION BY, ORDER BY, and the frame clause behave consistently across major engines. Small differences exist in frame defaults, supported functions, and performance behavior, so check each platform's official documentation before relying on edge-case behavior in production queries.

Are window functions faster than subqueries? Often, yes. A window function typically scans the table once and sorts within partitions, while a correlated subquery may re-read the table for each row. On columnar cloud warehouses the gap can be significant, though the best choice still depends on data volume, indexing, and how the engine plans the query.

Related articles

Want better software delivery?

See how we can make it happen.

Talk to our experts

No upfront fees. Start your project risk-free. No payment if unsatisfied with the first sprint.

Time BIX