Docs /Querying /Window functions
Querying

Window functions in BigQuery

How BigQuery window functions pick their frame. With ORDER BY the default frame is RANGE up to the current row, which makes LAST_VALUE return the current row and gives tied rows the same running total. Covers ROWS vs RANGE, the named WINDOW clause, LAG and LEAD, and PERCENTILE_CONT.

A window function computes a value for each row from a set of related rows, without collapsing them the way GROUP BY does. In BigQuery the set of rows is the partition (PARTITION BY), their order is ORDER BY, and the slice around the current row is the frame. Most surprises come from the frame: adding ORDER BY to a window silently changes it, and LAST_VALUE with the default frame returns the current row.

Partition, order and the default frame

SUM(amount) OVER (PARTITION BY region) has no ORDER BY, so the frame is the whole partition and every row gets the same total. Add ORDER BY day and the same function becomes a running total:

region total vs running total
12345678910111213
WITH sales AS (
  SELECT 'north' AS region, 1 AS day, 10 AS amount UNION ALL
  SELECT 'north', 2, 20 UNION ALL
  SELECT 'north', 3, 5 UNION ALL
  SELECT 'south', 1, 7 UNION ALL
  SELECT 'south', 2, 3
)
SELECT
  region, day, amount,
  SUM(amount) OVER (PARTITION BY region) AS region_total,
  SUM(amount) OVER (PARTITION BY region ORDER BY day) AS running_total
FROM sales
ORDER BY region, day
Result in BigQuery
regiondayamountregion_totalrunning_total
1north1103510
2north2203530
3north353535
4south17107
5south231010
5 rows

The rule from Google’s docs: with ORDER BY and no frame clause, the frame is RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. Without ORDER BY it is the whole partition.

ROWS vs RANGE: ties are the difference

ROWS counts physical rows. RANGE works on the value of the ORDER BY key, so rows with the same key are peers and all of them are in the frame together. Because the default is RANGE, tied rows get the same running total:

the default frame vs ROWS on a tie
123456789101112
WITH orders AS (
  SELECT 1 AS id, 1 AS day, 10 AS amount UNION ALL
  SELECT 2, 2, 20 UNION ALL
  SELECT 3, 2, 30 UNION ALL
  SELECT 4, 3, 40
)
SELECT
  id, day, amount,
  SUM(amount) OVER (ORDER BY day) AS default_frame,
  SUM(amount) OVER (ORDER BY day ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS rows_frame
FROM orders
ORDER BY id
Result in BigQuery
iddayamountdefault_framerows_frame
111101010
222206030
332306060
44340100100
4 rows

Orders 2 and 3 are on the same day. With the default frame both show 60, the total up to and including everything on day 2. With ROWS order 2 shows 30 and order 3 shows 60, and which of the two comes first is not defined unless the ORDER BY is unique. If you want a running total that moves one row at a time, write ROWS and make the order deterministic by adding a tiebreaker column.

RANGE can also take offsets, measured in key units rather than rows. Days 1, 2, 5 and 6 below have gaps, so “the last two days” and “the last two rows” differ on day 5:

RANGE 1 PRECEDING vs ROWS 1 PRECEDING
123456789101112
WITH readings AS (
  SELECT 1 AS day, 10 AS temp UNION ALL
  SELECT 2, 12 UNION ALL
  SELECT 5, 20 UNION ALL
  SELECT 6, 22
)
SELECT
  day, temp,
  SUM(temp) OVER (ORDER BY day RANGE BETWEEN 1 PRECEDING AND CURRENT ROW) AS last_2_days,
  SUM(temp) OVER (ORDER BY day ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) AS last_2_rows
FROM readings
ORDER BY day
Result in BigQuery
daytemplast_2_dayslast_2_rows
11101010
22122222
35202032
46224242
4 rows

An offset in RANGE needs exactly one ORDER BY key, and the key must be numeric. A DATE key is rejected:

RANGE with an offset on a DATEBigQuery rejects this query
123456
WITH readings AS (
  SELECT DATE '2026-03-01' AS day, 10 AS temp UNION ALL
  SELECT DATE '2026-03-02', 12
)
SELECT day, SUM(temp) OVER (ORDER BY day RANGE BETWEEN 1 PRECEDING AND CURRENT ROW) AS s
FROM readings
Error in BigQuery

ORDER BY key must be numeric in a RANGE-based window with OFFSET PRECEDING or OFFSET FOLLOWING boundaries, but has type DATE at [5:29]

To get “the previous 7 days” on dates, order by UNIX_DATE(day) (days as an integer) and use RANGE BETWEEN 6 PRECEDING AND CURRENT ROW. The offset in ROWS or RANGE must be a literal or a query parameter. A column reference is an error (“Window framing expression must be a literal or parameter”).

Moving averages with ROWS

For “the last 3 rows”, use ROWS BETWEEN 2 PRECEDING AND CURRENT ROW. Near the start the frame is shorter, not padded, so the first average is over one row:

3-row moving average
12345678910111213
WITH daily AS (
  SELECT 1 AS day, 100 AS visits UNION ALL
  SELECT 2, 130 UNION ALL
  SELECT 3, 90 UNION ALL
  SELECT 4, 160 UNION ALL
  SELECT 5, 120
)
SELECT
  day, visits,
  AVG(visits) OVER (ORDER BY day ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) AS avg_3_days,
  COUNT(*) OVER (ORDER BY day ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) AS rows_in_frame
FROM daily
ORDER BY day
Result in BigQuery
dayvisitsavg_3_daysrows_in_frame
11100100.01
22130115.02
3390106.666666666666673
44160126.666666666666673
55120123.333333333333333
5 rows

If you want NULL until the frame is full, compare COUNT(*) OVER (same frame) with the frame size.

LAST_VALUE and the default frame

FIRST_VALUE, LAST_VALUE and NTH_VALUE look at the frame. With ORDER BY and no frame clause the frame ends at the current row, so LAST_VALUE returns the current row’s own value:

LAST_VALUE with and without a full frame
12345678910111213
WITH logins AS (
  SELECT 'ana' AS user_name, 1 AS day UNION ALL
  SELECT 'ana', 4 UNION ALL
  SELECT 'ana', 9
)
SELECT
  user_name, day,
  LAST_VALUE(day) OVER (PARTITION BY user_name ORDER BY day) AS default_frame,
  LAST_VALUE(day) OVER (
    PARTITION BY user_name ORDER BY day
    ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS whole_partition
FROM logins
ORDER BY day
Result in BigQuery
user_namedaydefault_framewhole_partition
1ana119
2ana449
3ana999
3 rows

The default_frame column repeats day. To get the last row of the partition, extend the frame with ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING, or flip the order and use FIRST_VALUE.

IGNORE NULLS skips NULLs when picking the value. Together with the default frame it carries the last known value forward, which is the usual way to fill gaps:

fill NULLs with the last known value
123456789101112
WITH readings AS (
  SELECT 1 AS hour, 20.5 AS temp UNION ALL
  SELECT 2, NULL UNION ALL
  SELECT 3, NULL UNION ALL
  SELECT 4, 22.0
)
SELECT
  hour, temp,
  LAST_VALUE(temp) OVER (ORDER BY hour) AS last_value_default,
  LAST_VALUE(temp IGNORE NULLS) OVER (ORDER BY hour) AS carried_forward
FROM readings
ORDER BY hour
Result in BigQuery
hourtemplast_value_defaultcarried_forward
1120.520.520.5
22nullnull20.5
33nullnull20.5
4422.022.022.0
4 rows

LAG, LEAD and ranking functions have no frame

LAG(value, offset, default) and LEAD read a row at a fixed distance, so they ignore frames. BigQuery rejects a frame clause on them instead of ignoring it, and the same goes for RANK, DENSE_RANK and ROW_NUMBER:

a frame clause on LAGBigQuery rejects this query
123
WITH t AS (SELECT 1 AS day, 10 AS v UNION ALL SELECT 2, 20)
SELECT LAG(v) OVER (ORDER BY day ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) AS prev
FROM t
Error in BigQuery

Window framing clause is not allowed for analytic function lag at [2:34]

Without a row to read, LAG and LEAD return NULL unless you pass the third argument:

LAG, LEAD and the default value
12345678910111213
WITH prices AS (
  SELECT 1 AS day, 10.0 AS price UNION ALL
  SELECT 2, 12.0 UNION ALL
  SELECT 3, 9.0
)
SELECT
  day, price,
  LAG(price) OVER (ORDER BY day) AS prev,
  LAG(price, 1, 0.0) OVER (ORDER BY day) AS prev_or_zero,
  LEAD(price, 2) OVER (ORDER BY day) AS two_ahead,
  price - LAG(price) OVER (ORDER BY day) AS change
FROM prices
ORDER BY day
Result in BigQuery
daypriceprevprev_or_zerotwo_aheadchange
1110.0null0.09.0null
2212.010.010.0null2.0
339.012.012.0null-3.0
3 rows

The FIRST_VALUE, LAST_VALUE and NTH_VALUE functions do accept a frame clause. Google’s page on window function calls says only aggregate analytic functions can use one, which leaves them out, but a dry run of FIRST_VALUE(x) OVER (ORDER BY x ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) passes.

RANK leaves gaps after ties, DENSE_RANK doesn’t, and ROW_NUMBER numbers tied rows in an undefined order unless the ORDER BY breaks the tie:

RANK, DENSE_RANK and ROW_NUMBER on a tie
12345678910111213
WITH scores AS (
  SELECT 'ana' AS player, 90 AS score UNION ALL
  SELECT 'ben', 90 UNION ALL
  SELECT 'cy', 80 UNION ALL
  SELECT 'di', 70
)
SELECT
  player, score,
  RANK() OVER (ORDER BY score DESC) AS rank,
  DENSE_RANK() OVER (ORDER BY score DESC) AS dense_rank,
  ROW_NUMBER() OVER (ORDER BY score DESC, player) AS row_number
FROM scores
ORDER BY score DESC, player
Result in BigQuery
playerscorerankdense_rankrow_number
1ana90111
2ben90112
3cy80323
4di70434
4 rows

Named windows

When several functions share a window, name it once with a WINDOW clause after HAVING and refer to it with OVER w. A function can add a frame to a named window only if the named window has none. If it has one, BigQuery rejects the reference (“cannot reference another window that contains or inherits a window frame clause”):

one WINDOW clause, three functions
1234567891011121314
WITH sales AS (
  SELECT 'north' AS region, 1 AS day, 10 AS amount UNION ALL
  SELECT 'north', 2, 20 UNION ALL
  SELECT 'south', 1, 7 UNION ALL
  SELECT 'south', 2, 3
)
SELECT
  region, day,
  SUM(amount) OVER w AS running_total,
  SUM(amount) OVER (w ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) AS last_two,
  ROW_NUMBER() OVER w AS n
FROM sales
WINDOW w AS (PARTITION BY region ORDER BY day)
ORDER BY region, day
Result in BigQuery
regiondayrunning_totallast_twon
1north110101
2north230302
3south1771
4south210102
4 rows

PERCENTILE_CONT only exists as a window function

In BigQuery PERCENTILE_CONT and PERCENTILE_DISC are analytic functions only. There is no aggregate form for GROUP BY, so a call without OVER fails:

PERCENTILE_CONT without OVERBigQuery rejects this query
123
WITH latencies AS (SELECT 10 AS ms UNION ALL SELECT 20 UNION ALL SELECT 100)
SELECT PERCENTILE_CONT(ms, 0.5) AS median_ms
FROM latencies
Error in BigQuery

percentile_cont aggregate function is not supported.

With OVER (PARTITION BY ...) it works, and every row of the partition gets the same value, so use SELECT DISTINCT or QUALIFY ROW_NUMBER() ... = 1 if you want one row per group. PERCENTILE_CONT interpolates, so the median of 10, 20, 30, 100 is 25. PERCENTILE_DISC returns an actual value from the data, here 20. The percentile is the second argument, the value is the first, and the OVER clause can’t have its own ORDER BY:

interpolated vs discrete median
1234567891011121314
WITH latencies AS (
  SELECT 'api' AS service, 10 AS ms UNION ALL
  SELECT 'api', 20 UNION ALL
  SELECT 'api', 30 UNION ALL
  SELECT 'api', 100 UNION ALL
  SELECT 'web', 5 UNION ALL
  SELECT 'web', 15
)
SELECT
  service, ms,
  PERCENTILE_CONT(ms, 0.5) OVER (PARTITION BY service) AS median_ms,
  PERCENTILE_DISC(ms, 0.5) OVER (PARTITION BY service) AS median_disc
FROM latencies
ORDER BY service, ms
Result in BigQuery
servicemsmedian_msmedian_disc
1api1025.020
2api2025.020
3api3025.020
4api10025.020
5web510.05
6web1510.05
6 rows

If an approximate answer is enough and you want a plain GROUP BY, APPROX_QUANTILES is an aggregate function. See approximate aggregation.

Window functions can’t go in WHERE

WHERE runs before window functions are computed, so BigQuery rejects them there:

ROW_NUMBER() in WHEREBigQuery rejects this query
1234
WITH orders AS (SELECT 'c1' AS customer, 5 AS amount UNION ALL SELECT 'c1', 9)
SELECT customer, amount
FROM orders
WHERE ROW_NUMBER() OVER (PARTITION BY customer ORDER BY amount DESC) = 1
Error in BigQuery

Analytic function not allowed in WHERE clause at [4:7]

Filter on a window function with QUALIFY, which runs after them. See QUALIFY. In Querylab.io, a subquery that only wraps ROW_NUMBER(), RANK() or DENSE_RANK() to filter on it gets a hint with the quick fix “Convert to QUALIFY clause” (Code quality).

Window functions over aggregates

Window functions run after GROUP BY, so they can take an aggregate as their argument. SUM(SUM(amount)) OVER () adds up the per-group sums, which gives each group’s share of the total in one pass:

share of total after GROUP BY
1234567891011121314
WITH orders AS (
  SELECT 'c1' AS customer, 10 AS amount UNION ALL
  SELECT 'c1', 30 UNION ALL
  SELECT 'c2', 20 UNION ALL
  SELECT 'c3', 50
)
SELECT
  customer,
  SUM(amount) AS total,
  SUM(SUM(amount)) OVER () AS grand_total,
  ROUND(SUM(amount) / SUM(SUM(amount)) OVER (), 2) AS share
FROM orders
GROUP BY customer
ORDER BY customer
Result in BigQuery
customertotalgrand_totalshare
1c1401100.36
2c2201100.18
3c3501100.45
3 rows

What it costs

Window functions don’t change how many bytes a query reads. Bytes are billed for the columns read, as described in how queries are billed. A window does need each partition sorted, so a very large partition is slow. OVER () with no PARTITION BY makes the whole table one partition.