Window Functions

Window functions are PostgreSQL’s row-preserving analytics surface. They let a query rank rows, compare each row to prior or later rows, and compute running or partition-wide aggregates without collapsing the underlying detail rows. The OVER clause is what makes that possible: it defines the partition, ordering, and optional frame that each function can see.

Scope

This note mirrors the SQL Server window-function track with PostgreSQL semantics. It covers the OVER clause, ranking functions, offset and value functions, frame-sensitive aggregate windows, the named WINDOW clause, and percentile-style analytics.

  • Window fundamentals show how PARTITION BY differs from GROUP BY and why window functions annotate rows instead of collapsing them.
  • Ranking functions cover ROW_NUMBER, RANK, and DENSE_RANK for deterministic selection and tie handling.
  • Offset and value functions cover LAG, LEAD, and the LAST_VALUE frame trap.
  • Aggregate windows cover running and moving calculations with explicit ROWS frames.
  • Named windows and percentiles cover reusable window specifications and PostgreSQL’s percentile_cont ordered-set aggregate.

Window Function Fundamentals

The defining property of a window function is that every input row survives. A plain aggregate with GROUP BY produces one row per group. A window function computes across a logical neighborhood of rows and returns one value per input row.

OVER, PARTITION BY, and row preservation

Window functions turn aggregates and ranking operators into per-row annotations. The PARTITION BY clause controls where calculations reset, and the ORDER BY clause controls row sequence for ranking, offsets, and running frames.

Use PARTITION BY when every detail row must stay visible

Use this pattern when a report needs row-level detail plus group-level context in the same result. It is typically triggered by rankings, scorecards, and row-by-row diagnostics. The query is read-only. Its purpose is to show that a window aggregate broadcasts a partition-level value back onto every row instead of reducing the result set.

FieldSourceTypeMeaning
symbolgold.scores_daily.symbolvarcharSymbol row that remains visible in the result.
score_dategold.scores_daily.score_datedateCurrent score date of the row.
composite_scoregold.scores_daily.composite_scorenumericCurrent row’s score value.
avg_score_per_indexAVG(composite_score) OVER (PARTITION BY _index)numericAverage score across the entire selected index partition.

This query keeps the latest USA score rows visible while attaching the partition-wide average score to each row.

SELECT
    symbol,
    score_date,
    ROUND(composite_score::numeric, 4) AS composite_score,
    ROUND(AVG(composite_score) OVER (PARTITION BY _index)::numeric, 4) AS avg_score_per_index
FROM gold.scores_daily
WHERE score_date = (SELECT MAX(score_date) FROM gold.scores_daily)
  AND _index = 'stoxx_usa_50'
ORDER BY composite_score DESC NULLS LAST
LIMIT 5;
symbolscore_datecomposite_scoreavg_score_per_index
MU2026-04-081.36170.0091
AMD2026-04-080.54080.0091
AVGO2026-04-080.52510.0091
AMZN2026-04-080.49470.0091
NVDA2026-04-080.49050.0091

Five detail rows remained five output rows. The index-wide average is attached to each row as context, which is exactly what a window function is for.

Contrast PARTITION BY with GROUP BY

Use this comparison when a query author needs to decide whether the result should keep detail rows or reduce to one row per group. It is typically triggered during refactors from self-join aggregates to window logic. The query is read-only. Its purpose is to show the row-collapse boundary explicitly.

FieldSourceTypeMeaning
_indexgold.scores_daily._indexvarcharGrouping key of the aggregate result.
avg_score_per_indexAVG(composite_score)numericAverage score of the grouped result.
row_countCOUNT(*)bigintNumber of rows collapsed into the grouped output row.

This grouped query returns one row for the whole USA scoring slice instead of preserving the 50 symbol rows.

SELECT
    _index,
    ROUND(AVG(composite_score)::numeric, 4) AS avg_score_per_index,
    COUNT(*) AS row_count
FROM gold.scores_daily
WHERE score_date = (SELECT MAX(score_date) FROM gold.scores_daily)
  AND _index = 'stoxx_usa_50'
GROUP BY _index;
_indexavg_score_per_indexrow_count
stoxx_usa_500.009150

The average is the same number as in the windowed query, but the result shape is completely different. GROUP BY keeps one row for the group. The windowed version keeps one row per symbol.

Ranking and Positional Functions

Ranking functions assign row positions inside each partition. They depend entirely on the ORDER BY inside OVER, so tie-breakers must be deliberate whenever the chosen winner matters.

Deterministic selection and tie handling

ROW_NUMBER forces one unique winner per ordered partition. RANK and DENSE_RANK preserve ties but number them differently.

Use ROW_NUMBER for top-N-per-group and deduplication

Use ROW_NUMBER when the query must choose exactly one winning row or the first N rows inside each partition. It is typically triggered by latest-row selection, deduplication, and top-per-group reporting. The query is read-only. Its purpose is to show the standard PostgreSQL ranking pattern for one row per sector.

FieldSourceTypeMeaning
symbolgold.scores_daily.symbolvarcharWinning symbol returned for the sector partition.
sectorgold.scores_daily.sectorvarcharPartition key that resets the row numbering.
composite_scoregold.scores_daily.composite_scorenumericScore that drives the ordering inside the sector.
rnROW_NUMBER() OVER (...)bigintUnique position within each sector partition.

This query ranks the latest USA score rows within each sector and keeps only the first row from each partition.

WITH ranked AS (
    SELECT
        symbol,
        sector,
        ROUND(composite_score::numeric, 4) AS composite_score,
        ROW_NUMBER() OVER (
            PARTITION BY sector
            ORDER BY composite_score DESC NULLS LAST, symbol
        ) AS rn
    FROM gold.scores_daily
    WHERE score_date = (SELECT MAX(score_date) FROM gold.scores_daily)
      AND _index = 'stoxx_usa_50'
)
SELECT
    symbol,
    sector,
    composite_score,
    rn
FROM ranked
WHERE rn = 1
ORDER BY sector;
symbolsectorcomposite_scorern
LINBasic Materials0.03721
GOOGLCommunication Services0.17971
AMZNConsumer Cyclical0.49471
PMConsumer Defensive0.23591
CVXEnergy0.16301
BACFinancial Services0.40821
MRKHealthcare0.33291
RTXIndustrials0.21061
MUTechnology1.36171

The extra symbol tie-breaker is what makes the selection deterministic if two rows share the same score. Without it, the winning row inside a tie could vary.

Distinguish ROW_NUMBER, RANK, and DENSE_RANK when ties matter

Use this comparison when tied values are part of the business meaning and the query author needs to decide whether later ranks should skip numbers or stay contiguous. It is typically triggered by leaderboards, percentiles, and audit queries that must explain tie handling clearly. The query is read-only. Its purpose is to show the exact numbering differences on a compact tied dataset.

FieldSourceTypeMeaning
symbolinline sample rowsettextLabel of the sample row.
composite_scoreinline sample rowsetnumericScore used for ordering and tie formation.
row_numberROW_NUMBER() OVER (...)bigintUnique sequential numbering.
rank_valueRANK() OVER (...)bigintTie-preserving rank with gaps after ties.
dense_rank_valueDENSE_RANK() OVER (...)bigintTie-preserving rank without gaps.

This query shows how the three ranking functions number the same tied score set differently.

WITH sample(symbol, composite_score) AS (
    VALUES
        ('alpha', 0.90::numeric),
        ('bravo', 0.90::numeric),
        ('charlie', 0.75::numeric),
        ('delta', 0.60::numeric)
)
SELECT
    symbol,
    composite_score,
    ROW_NUMBER() OVER (ORDER BY composite_score DESC, symbol) AS row_number,
    RANK() OVER (ORDER BY composite_score DESC) AS rank_value,
    DENSE_RANK() OVER (ORDER BY composite_score DESC) AS dense_rank_value
FROM sample
ORDER BY composite_score DESC, symbol;
symbolcomposite_scorerow_numberrank_valuedense_rank_value
alpha0.90111
bravo0.90211
charlie0.75332
delta0.60443

The tie between alpha and bravo is what reveals the difference. RANK leaves a gap after the tie group. DENSE_RANK does not. ROW_NUMBER ignores tie semantics and simply assigns unique positions.

Offset and Frame-Sensitive Value Functions

Offset functions compare a row to earlier or later rows in the ordered partition. Value functions such as LAST_VALUE are especially sensitive to frame definitions, which is why explicit frames are safer than defaults.

Previous-row, next-row, and last-row semantics

These functions are usually simpler and clearer than self joins, but only if the ordering inside the window specification is complete and deliberate.

Use LAG and LEAD for prior-row and next-row comparisons

Use this pattern when each row must see the previous or next row in the same ordered series. It is typically triggered by day-over-day change, state-transition, and gap analysis. The query is read-only. Its purpose is to show the standard PostgreSQL alternative to self-join look-back logic.

FieldSourceTypeMeaning
perf_dategold.index_performance.perf_datedateOrdered date axis of the return series.
daily_returngold.index_performance.daily_returnnumericReturn on the current row.
prev_returnLAG(daily_return)numericReturn from the prior ordered row in the same partition.
next_returnLEAD(daily_return)numericReturn from the next ordered row in the same partition.

This query looks backward and forward across the recent STOXX USA 50 return series.

SELECT
    perf_date,
    ROUND(daily_return::numeric, 6) AS daily_return,
    ROUND(LAG(daily_return) OVER (ORDER BY perf_date)::numeric, 6) AS prev_return,
    ROUND(LEAD(daily_return) OVER (ORDER BY perf_date)::numeric, 6) AS next_return
FROM gold.index_performance
WHERE _index = 'stoxx_usa_50'
  AND perf_date BETWEEN DATE '2026-04-01' AND DATE '2026-04-07'
ORDER BY perf_date;
perf_datedaily_returnprev_returnnext_return
2026-04-010.0061270.000955
2026-04-020.0009550.0061270.004856
2026-04-060.0048560.0009550.000962
2026-04-070.0009620.004856

The first row has no predecessor and the last row has no successor, so PostgreSQL returns NULL for those offsets. That nullability is normal and should be handled explicitly when downstream calculations depend on a default value.

LAST_VALUE needs an explicit whole-partition frame

Use this pattern when a query needs the true final value in the partition, not merely the last value visible inside the default running frame. It is typically triggered by end-of-period baselines, drawdown reference points, and comparisons against the final row. The query is read-only. Its purpose is to make the default-frame trap visible with real output.

FieldSourceTypeMeaning
perf_dategold.index_performance.perf_datedateOrdered row within the partition.
daily_returngold.index_performance.daily_returnnumericCurrent row’s return value.
last_value_default_frameLAST_VALUE(...) OVER (ORDER BY perf_date)numericLast value in the default running frame, which ends at the current row.
last_value_full_partitionLAST_VALUE(...) OVER (... ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING)numericTrue last value of the full partition.

This query contrasts PostgreSQL’s default LAST_VALUE frame with an explicit whole-partition frame.

SELECT
    perf_date,
    ROUND(daily_return::numeric, 6) AS daily_return,
    ROUND(LAST_VALUE(daily_return) OVER (
        ORDER BY perf_date
    )::numeric, 6) AS last_value_default_frame,
    ROUND(LAST_VALUE(daily_return) OVER (
        ORDER BY perf_date
        ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
    )::numeric, 6) AS last_value_full_partition
FROM gold.index_performance
WHERE _index = 'stoxx_usa_50'
  AND perf_date BETWEEN DATE '2026-04-01' AND DATE '2026-04-07'
ORDER BY perf_date;
perf_datedaily_returnlast_value_default_framelast_value_full_partition
2026-04-010.0061270.0061270.000962
2026-04-020.0009550.0009550.000962
2026-04-060.0048560.0048560.000962
2026-04-070.0009620.0009620.000962

The default frame ends at the current row, so the “last” value is often just the current row itself. When the business meaning is “final value in the partition,” the frame must be widened explicitly.

Aggregate Windows and Frames

Windowed aggregates become running, moving, or whole-partition calculations depending on their frame. In PostgreSQL, explicit ROWS frames are the safest default when row-by-row movement matters.

Running and moving calculations

The difference between a running total and a moving average is not the aggregate function. It is the frame definition.

Use an explicit ROWS frame for running aggregates

Use this pattern when the calculation should accumulate from the start of the partition up to the current row. It is typically triggered by running totals, cumulative returns, and running maxima or minima. The query is read-only. Its purpose is to show the canonical running-frame pattern.

FieldSourceTypeMeaning
perf_dategold.index_performance.perf_datedateOrdered row inside the return series.
daily_returngold.index_performance.daily_returnnumericCurrent return contribution.
running_return_sumSUM(daily_return) OVER (ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)numericCumulative sum up to the current row.

This query computes a running sum of recent STOXX USA 50 daily returns.

SELECT
    perf_date,
    ROUND(daily_return::numeric, 6) AS daily_return,
    ROUND(SUM(daily_return) OVER (
        ORDER BY perf_date
        ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
    )::numeric, 6) AS running_return_sum
FROM gold.index_performance
WHERE _index = 'stoxx_usa_50'
  AND perf_date BETWEEN DATE '2026-04-01' AND DATE '2026-04-07'
ORDER BY perf_date;
perf_datedaily_returnrunning_return_sum
2026-04-010.0061270.006127
2026-04-020.0009550.007082
2026-04-060.0048560.011938
2026-04-070.0009620.012899

Because the frame grows row by row, the cumulative sum never decreases in row coverage even though the input values themselves vary.

Shrink the frame for moving calculations

Use a bounded frame when each row should see only a fixed-size neighborhood instead of the full partition prefix. It is typically triggered by moving averages, rolling volatility, and short-horizon smoothing. The query is read-only. Its purpose is to show that a moving metric is a frame choice, not a different function family.

FieldSourceTypeMeaning
perf_dategold.index_performance.perf_datedateOrdered row inside the time series.
daily_returngold.index_performance.daily_returnnumericCurrent return on the row.
two_row_moving_avgAVG(daily_return) OVER (ROWS BETWEEN 1 PRECEDING AND CURRENT ROW)numericAverage over the current row and the immediately preceding row.

This query computes a two-row moving average across the recent return series.

SELECT
    perf_date,
    ROUND(daily_return::numeric, 6) AS daily_return,
    ROUND(AVG(daily_return) OVER (
        ORDER BY perf_date
        ROWS BETWEEN 1 PRECEDING AND CURRENT ROW
    )::numeric, 6) AS two_row_moving_avg
FROM gold.index_performance
WHERE _index = 'stoxx_usa_50'
  AND perf_date BETWEEN DATE '2026-04-01' AND DATE '2026-04-07'
ORDER BY perf_date;
perf_datedaily_returntwo_row_moving_avg
2026-04-010.0061270.006127
2026-04-020.0009550.003541
2026-04-060.0048560.002905
2026-04-070.0009620.002909

The first row only has one visible row in its frame, so the moving average equals the current value. Every later row averages the current return with the immediately previous return.

Named Windows and Percentile Analytics

PostgreSQL supports the WINDOW clause, which lets a query define reusable window specifications once and reference them from multiple functions. PostgreSQL also provides ordered-set aggregates such as percentile_cont, but unlike some SQL Server examples they are usually written as grouped aggregates rather than as window functions.

Reuse the window specification, then compute distribution cut points

This section covers two adjacent ideas: keeping window definitions aligned with a named window, and computing medians or other percentiles with ordered-set aggregates.

Reuse a named window specification across multiple functions

Use a named window when several functions share the same partition or ordering and the query would otherwise repeat the specification several times. It is typically triggered by report-style queries that rank and summarize the same partition together. The query is read-only. Its purpose is to show PostgreSQL’s reusable WINDOW syntax.

FieldSourceTypeMeaning
symbolgold.scores_daily.symbolvarcharSymbol row in the latest USA scoring slice.
sectorgold.scores_daily.sectorvarcharSector partition reused by both named windows.
composite_scoregold.scores_daily.composite_scorenumericScore used both for ordering and for sector average calculation.
sector_row_numberROW_NUMBER() OVER wbigintPosition of the row inside the ordered sector partition.
sector_avg_scoreAVG(composite_score) OVER w_allnumericAverage score of the full sector partition.

This query reuses named window specifications so ranking and averaging stay aligned by sector.

SELECT
    symbol,
    sector,
    ROUND(composite_score::numeric, 4) AS composite_score,
    ROW_NUMBER() OVER w AS sector_row_number,
    ROUND(AVG(composite_score) OVER w_all::numeric, 4) AS sector_avg_score
FROM gold.scores_daily
WHERE score_date = (SELECT MAX(score_date) FROM gold.scores_daily)
  AND _index = 'stoxx_usa_50'
WINDOW
    w AS (PARTITION BY sector ORDER BY composite_score DESC NULLS LAST, symbol),
    w_all AS (PARTITION BY sector)
ORDER BY sector, sector_row_number
LIMIT 8;
symbolsectorcomposite_scoresector_row_numbersector_avg_score
LINBasic Materials0.037210.0372
GOOGLCommunication Services0.179710.0086
METACommunication Services0.158420.0086
VZCommunication Services0.094530.0086
TMUSCommunication Services0.061040.0086
NFLXCommunication Services-0.450550.0086
AMZNConsumer Cyclical0.49471-0.2134
MCDConsumer Cyclical-0.16662-0.2134

The WINDOW clause does not materialize anything. It simply keeps repeated partition definitions consistent and easier to review.

Use percentile_cont for median-style distribution cuts

Use this pattern when the business question is about distribution cut points rather than row numbering. It is typically triggered by medians, percentile thresholds, and score-distribution summaries. The query is read-only. Its purpose is to show PostgreSQL’s ordered-set aggregate form of percentile analytics.

FieldSourceTypeMeaning
sectorgold.scores_daily.sectorvarcharSector group for the percentile calculation.
median_scorepercentile_cont(0.5) WITHIN GROUP (ORDER BY composite_score)numericContinuous median of sector composite scores.
sector_rowsCOUNT(*)bigintNumber of rows contributing to the median.

This query computes sector medians for the latest USA score slice by using PostgreSQL’s ordered-set aggregate syntax.

SELECT
    sector,
    ROUND(percentile_cont(0.5) WITHIN GROUP (ORDER BY composite_score)::numeric, 4) AS median_score,
    COUNT(*) AS sector_rows
FROM gold.scores_daily
WHERE score_date = (SELECT MAX(score_date) FROM gold.scores_daily)
  AND _index = 'stoxx_usa_50'
  AND composite_score IS NOT NULL
GROUP BY sector
HAVING COUNT(*) >= 3
ORDER BY median_score DESC, sector
LIMIT 6;
sectormedian_scoresector_rows
Technology0.269815
Communication Services0.09455
Industrials0.00583
Healthcare-0.11555
Financial Services-0.11709
Consumer Defensive-0.14786

percentile_cont can interpolate between row values, so the returned median is a distribution statistic, not necessarily one of the original input values. In PostgreSQL this belongs with analytical SQL, even though the syntax is an ordered-set aggregate instead of an OVER-driven window function.

Practical Rules

Use window functions when the result must keep the detail row and attach analytical context to it. Switch back to GROUP BY when one row per group is the real target.

NeedPostgreSQL patternWhy
Keep detail rows plus group contextWindow aggregate with OVERPreserves one output row per input row.
Pick one winning row per partitionROW_NUMBER() with a deterministic tie-breakerMakes survivorship explicit.
Preserve ties in rankingRANK() or DENSE_RANK()Choose gap or no-gap semantics deliberately.
Compare to prior or next rowLAG() / LEAD()Replaces self joins cleanly.
Get the true last row’s valueExplicit whole-partition frameAvoids the LAST_VALUE default-frame trap.
Running metricROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROWGives a row-by-row growing frame.
Moving metricBounded ROWS frameRestricts visibility to a fixed neighborhood.
Reuse the same window specificationWINDOW clauseReduces duplication and drift between functions.