[{"data":1,"prerenderedAt":183},["ShallowReactive",2],{"qa-\u002Fsql\u002Fwindow-functions\u002Fframes-and-offsets":3},{"page":4,"siblings":171,"blog":180},{"id":5,"title":6,"body":7,"description":74,"difficulty":78,"extension":79,"framework":80,"frameworkSlug":81,"meta":82,"navigation":83,"order":84,"path":85,"questions":86,"questionsCount":161,"related":162,"seo":163,"seoDescription":164,"stem":165,"subtopic":166,"topic":167,"topicSlug":168,"updated":169,"__hash__":170},"qa\u002Fsql\u002Fwindow-functions\u002Fframes-and-offsets.md","Frames And Offsets",{"type":8,"value":9,"toc":73},"minimark",[10,15],[11,12,14],"h2",{"id":13},"about-frames-offset-functions","About Frames & Offset Functions",[16,17,18,19,23,24,27,28,31,32,35,36,31,39,31,42,45,46,55,56,59,60,63,64,68,69,72],"p",{},"Frame clauses (",[20,21,22],"code",{},"ROWS","\u002F",[20,25,26],{},"RANGE BETWEEN ...",") and offset functions (",[20,29,30],{},"LAG",", ",[20,33,34],{},"LEAD",",\n",[20,37,38],{},"FIRST_VALUE",[20,40,41],{},"LAST_VALUE",[20,43,44],{},"NTH_VALUE",") power SQL's most advanced analytics — moving\naverages, period-over-period deltas, and gaps-and-islands. The highest-value interview\ntraps are the ",[47,48,49,51,52],"strong",{},[20,50,22],{}," vs ",[20,53,54],{},"RANGE"," distinction, the ",[47,57,58],{},"default frame"," (",[20,61,62],{},"RANGE ... CURRENT ROW","), and the ",[47,65,66],{},[20,67,41],{}," gotcha that needs an explicit ",[20,70,71],{},"UNBOUNDED FOLLOWING"," frame.",{"title":74,"searchDepth":75,"depth":75,"links":76},"",2,[77],{"id":13,"depth":75,"text":14},"hard","md","SQL","sql",{},true,3,"\u002Fsql\u002Fwindow-functions\u002Fframes-and-offsets",[87,92,96,100,104,108,112,117,121,125,129,133,137,141,145,149,153,157],{"id":88,"difficulty":89,"q":90,"a":91},"what-is-a-window-frame","medium","What is a window frame?","A **window frame** narrows the window to a **subset of rows around the current\nrow**, within its partition. It's defined with a `ROWS` or `RANGE` clause inside\n`OVER`, and controls exactly which rows an aggregate like `SUM` or `AVG` sees.\n\n```sql\n-- 3-row moving average: current row + the two before it\nSELECT day, amount,\n       AVG(amount) OVER (ORDER BY day\n                         ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) AS moving_avg\nFROM   sales;\n```\n\nFrames enable **moving averages, running totals over a sliding window, and\nlookback\u002Flookahead aggregates**.\n\nRule of thumb: a frame is the sliding sub-window an aggregate operates on — set it\nwith `ROWS`\u002F`RANGE BETWEEN ... AND ...`.\n",{"id":93,"difficulty":78,"q":94,"a":95},"rows-vs-range","What is the difference between ROWS and RANGE in a frame?","Both define the frame boundaries, but they count differently:\n\n- `ROWS` works on **physical row positions** — \"the 2 rows before this one,\"\n  regardless of their values.\n- `RANGE` works on **value ranges** of the `ORDER BY` column — rows whose ordering\n  value falls within an offset, treating **ties (peers) as a unit**.\n\n```sql\n-- ROWS: exactly 3 physical rows\nSUM(x) OVER (ORDER BY day ROWS BETWEEN 2 PRECEDING AND CURRENT ROW)\n\n-- RANGE: all rows with the same day are included together\nSUM(x) OVER (ORDER BY day RANGE BETWEEN 2 PRECEDING AND CURRENT ROW)\n```\n\nWith duplicate `ORDER BY` values, `RANGE` includes **all** peer rows; `ROWS`\ncounts each physically.\n\nRule of thumb: `ROWS` = count physical rows; `RANGE` = include all rows with values\nin range (peers grouped).\n",{"id":97,"difficulty":78,"q":98,"a":99},"default-frame","What is the default window frame when you specify ORDER BY but no frame?","When you add `ORDER BY` to an aggregate window **without** an explicit frame, the\ndefault is `RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW`. This gives a\n**running (cumulative)** aggregate — but because it's `RANGE`, **tied rows are\nincluded together** through the current row.\n\n```sql\n-- default frame = running total, but RANGE means peers are summed together\nSUM(amount) OVER (ORDER BY day)\n```\n\nThis trips people up: with duplicate `day` values, each tied row shows the **same**\ncumulative total (all peers included). Use `ROWS` for true row-by-row accumulation.\n\nRule of thumb: `ORDER BY` with no frame = `RANGE ... UNBOUNDED PRECEDING TO CURRENT\nROW`; switch to `ROWS` to avoid peer-grouping surprises.\n",{"id":101,"difficulty":89,"q":102,"a":103},"no-order-by-frame","What is the window frame when there is no ORDER BY?","With **no `ORDER BY`** in the `OVER` clause, the frame is the **entire partition** —\nevery row in the partition sees all of them. The aggregate is the same for all rows\nin that partition.\n\n```sql\n-- every row gets the department total (whole partition)\nSUM(salary) OVER (PARTITION BY dept_id) AS dept_total\n```\n\nThis is why `SUM(x) OVER (PARTITION BY g)` gives a group total on each row, while\nadding `ORDER BY` turns it into a running total.\n\nRule of thumb: no `ORDER BY` → frame is the full partition (a flat group aggregate);\nadding `ORDER BY` makes it cumulative.\n",{"id":105,"difficulty":78,"q":106,"a":107},"frame-boundaries","What are the possible frame boundary keywords?","A frame is `BETWEEN \u003Cstart> AND \u003Cend>`, where each bound is one of:\n\n- `UNBOUNDED PRECEDING` — the first row of the partition.\n- `n PRECEDING` — n rows (or values) before the current row.\n- `CURRENT ROW` — the current row (or its peers under `RANGE`).\n- `n FOLLOWING` — n rows (or values) after the current row.\n- `UNBOUNDED FOLLOWING` — the last row of the partition.\n\n```sql\n-- centered 3-row average: one before, current, one after\nAVG(x) OVER (ORDER BY day ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING)\n\n-- whole partition\nSUM(x) OVER (ORDER BY day ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING)\n```\n\nRule of thumb: combine `UNBOUNDED`\u002F`n PRECEDING`, `CURRENT ROW`, `n FOLLOWING`\u002F\n`UNBOUNDED` to frame any sliding or anchored window.\n",{"id":109,"difficulty":89,"q":110,"a":111},"moving-average","How do you compute a moving average?","Use `AVG` with a `ROWS` frame spanning the desired window of rows around the\ncurrent row.\n\n```sql\n-- 7-day moving average (current day + previous 6)\nSELECT day, amount,\n       AVG(amount) OVER (ORDER BY day\n                         ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS ma_7\nFROM   sales;\n```\n\nUse `ROWS` (not `RANGE`) for a fixed count of rows. For a **centered** average,\nframe `BETWEEN 3 PRECEDING AND 3 FOLLOWING`. Early rows average fewer rows unless\nyou handle warm-up.\n\nRule of thumb: moving average = `AVG(x) OVER (ORDER BY t ROWS BETWEEN n PRECEDING\nAND CURRENT ROW)`.\n",{"id":113,"difficulty":114,"q":115,"a":116},"lag-function","easy","What does the LAG() function do?","`LAG(col, offset, default)` returns the value of `col` from a **previous row**\nwithin the partition — `offset` rows back (default 1). If there's no such row, it\nreturns `default` (or `NULL`). It's the classic tool for **comparing a row to the\nprior one**.\n\n```sql\n-- compare each day's sales to the previous day\nSELECT day, amount,\n       LAG(amount, 1, 0) OVER (ORDER BY day) AS prev_amount,\n       amount - LAG(amount) OVER (ORDER BY day) AS day_over_day\nFROM   sales;\n```\n\nRule of thumb: `LAG()` looks **backward** to the previous row — perfect for\nperiod-over-period deltas.\n",{"id":118,"difficulty":114,"q":119,"a":120},"lead-function","What does the LEAD() function do?","`LEAD(col, offset, default)` is the mirror of `LAG` — it returns a value from a\n**following row**, `offset` rows ahead (default 1), or `default`\u002F`NULL` past the\nend. Use it to look **forward**.\n\n```sql\n-- gap until the customer's next order\nSELECT customer_id, order_date,\n       LEAD(order_date) OVER (PARTITION BY customer_id\n                              ORDER BY order_date) AS next_order,\n       LEAD(order_date) OVER (PARTITION BY customer_id\n                              ORDER BY order_date) - order_date AS days_gap\nFROM   orders;\n```\n\nRule of thumb: `LEAD()` looks **forward** to the next row — use it for \"time until\nnext event\" or next-value comparisons.\n",{"id":122,"difficulty":89,"q":123,"a":124},"lag-lead-offset-default","What are the optional arguments of LAG() and LEAD()?","Both take three arguments: `LAG(expr [, offset [, default]])`.\n\n- `expr` — the column\u002Fexpression to fetch.\n- `offset` — how many rows back\u002Fforward (default `1`).\n- `default` — value returned when the offset falls outside the partition\n  (default `NULL`).\n\n```sql\n-- value 2 rows back; if none, use 0 instead of NULL\nLAG(amount, 2, 0) OVER (ORDER BY day)\n```\n\nSupplying a `default` is the clean way to avoid `NULL`s at partition edges (e.g.\nthe first row's \"previous\" value).\n\nRule of thumb: pass `offset` to jump multiple rows and `default` to replace the\nedge `NULL`s.\n",{"id":126,"difficulty":89,"q":127,"a":128},"first-value-last-value","What do FIRST_VALUE() and LAST_VALUE() return?","`FIRST_VALUE(col)` returns `col` from the **first row of the frame**;\n`LAST_VALUE(col)` from the **last row of the frame**. They're handy for putting a\npartition's boundary value on every row.\n\n```sql\nSELECT name, dept_id, salary,\n       FIRST_VALUE(name) OVER (PARTITION BY dept_id\n                               ORDER BY salary DESC) AS highest_paid\nFROM   employees;\n```\n\n**Gotcha:** with the default frame (`... CURRENT ROW`), `LAST_VALUE` returns the\ncurrent row, not the partition's true last — you must widen the frame (see next\nquestion).\n\nRule of thumb: `FIRST_VALUE`\u002F`LAST_VALUE` grab a frame boundary value — mind the\nframe, especially for `LAST_VALUE`.\n",{"id":130,"difficulty":78,"q":131,"a":132},"last-value-frame-gotcha","Why does LAST_VALUE() often return the current row instead of the last?","Because the default frame with `ORDER BY` is `RANGE BETWEEN UNBOUNDED PRECEDING AND\nCURRENT ROW` — the frame **ends at the current row**, so `LAST_VALUE` sees the\ncurrent row as the last. To get the partition's true last value, **extend the frame\nto `UNBOUNDED FOLLOWING`**.\n\n```sql\n-- WRONG: returns current row's value\nLAST_VALUE(salary) OVER (PARTITION BY dept_id ORDER BY salary)\n\n-- RIGHT: frame covers the whole partition\nLAST_VALUE(salary) OVER (PARTITION BY dept_id ORDER BY salary\n                         ROWS BETWEEN UNBOUNDED PRECEDING\n                                  AND UNBOUNDED FOLLOWING)\n```\n\nRule of thumb: always set an explicit `... UNBOUNDED FOLLOWING` frame with\n`LAST_VALUE`, or it just returns the current row.\n",{"id":134,"difficulty":89,"q":135,"a":136},"nth-value","What does NTH_VALUE() do?","`NTH_VALUE(col, n)` returns `col` from the **nth row of the frame** (1-based). Like\n`LAST_VALUE`, it's frame-sensitive, so widen the frame to see the whole partition.\n\n```sql\n-- the 2nd highest-paid employee's name, shown on every row of the dept\nSELECT name, dept_id,\n       NTH_VALUE(name, 2) OVER (PARTITION BY dept_id ORDER BY salary DESC\n                                ROWS BETWEEN UNBOUNDED PRECEDING\n                                         AND UNBOUNDED FOLLOWING) AS second_top\nFROM   employees;\n```\n\nRows before the nth position return `NULL`. (Supported in PostgreSQL, MySQL 8+;\nnot in SQL Server.)\n\nRule of thumb: `NTH_VALUE(col, n)` fetches the nth frame row — widen the frame to\ntarget the whole partition.\n",{"id":138,"difficulty":89,"q":139,"a":140},"running-total-vs-frame","How does the frame turn an aggregate into a running vs windowed total?","The frame decides what an aggregate accumulates:\n\n- **Running total** — `ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW`\n  (everything up to now).\n- **Sliding window total** — `ROWS BETWEEN n PRECEDING AND CURRENT ROW`\n  (last n+1 rows).\n- **Full partition total** — `ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED\n  FOLLOWING` (or omit `ORDER BY`).\n\n```sql\nSUM(x) OVER (ORDER BY day ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) -- running\nSUM(x) OVER (ORDER BY day ROWS BETWEEN 6 PRECEDING AND CURRENT ROW)         -- last 7\n```\n\nRule of thumb: the frame's start bound sets the accumulation window — `UNBOUNDED\nPRECEDING` for running, `n PRECEDING` for sliding.\n",{"id":142,"difficulty":89,"q":143,"a":144},"lag-lead-vs-self-join","Why use LAG\u002FLEAD instead of a self-join for row comparisons?","Comparing each row to its neighbor with a **self-join** is verbose, needs a way to\ndefine \"previous\" (often a correlated subquery with `MAX(... \u003C current)`), and is\nslow. `LAG`\u002F`LEAD` do it in a **single ordered pass**, clearly and efficiently.\n\n```sql\n-- self-join approach (awkward, slower)\nSELECT a.day, a.amount - b.amount AS delta\nFROM sales a LEFT JOIN sales b ON b.day = a.day - 1;\n\n-- LAG approach (clean, one pass)\nSELECT day, amount - LAG(amount) OVER (ORDER BY day) AS delta FROM sales;\n```\n\nRule of thumb: prefer `LAG`\u002F`LEAD` over self-joins for previous\u002Fnext comparisons —\nsimpler and faster.\n",{"id":146,"difficulty":78,"q":147,"a":148},"gaps-and-islands","How do offset\u002Franking functions solve gaps-and-islands problems?","**Gaps-and-islands** problems (finding consecutive runs or missing ranges) are\nsolved by spotting where sequences break — using `LAG`\u002F`LEAD` to detect gaps, or the\n\"`ROW_NUMBER` difference\" trick to label islands.\n\n```sql\n-- group consecutive login days into \"islands\"\nWITH marked AS (\n    SELECT user_id, login_date,\n           login_date - (ROW_NUMBER() OVER (PARTITION BY user_id\n                                            ORDER BY login_date)) * INTERVAL '1 day'\n           AS grp\n    FROM logins\n)\nSELECT user_id, MIN(login_date) AS streak_start, MAX(login_date) AS streak_end,\n       COUNT(*) AS streak_len\nFROM marked GROUP BY user_id, grp;\n```\n\nRows in the same run share a constant `grp` value because the date and row number\nadvance together.\n\nRule of thumb: detect gaps with `LAG`\u002F`LEAD`; identify islands with the\ndate-minus-`ROW_NUMBER` constant-group trick.\n",{"id":150,"difficulty":78,"q":151,"a":152},"frame-with-range-interval","Can RANGE frames use intervals (e.g. time-based windows)?","Yes — `RANGE` frames can use a **value offset**, including time intervals, so the\nwindow is defined by the `ORDER BY` value rather than a row count. This gives true\ntime-based windows even when rows are irregularly spaced.\n\n```sql\n-- sum of sales in the trailing 7 days by date value (not row count)\nSELECT day, amount,\n       SUM(amount) OVER (ORDER BY day\n                         RANGE BETWEEN INTERVAL '7 days' PRECEDING\n                                   AND CURRENT ROW) AS rolling_7d\nFROM sales;\n```\n\nUnlike `ROWS`, this correctly handles missing days and multiple rows per day.\n(PostgreSQL and modern engines support `RANGE` with numeric\u002Finterval offsets.)\n\nRule of thumb: use `RANGE` with an interval\u002Fnumeric offset for value-based windows\n(e.g. \"last 7 days\") that don't depend on row counts.\n",{"id":154,"difficulty":89,"q":155,"a":156},"offset-functions-null-handling","How do LAG\u002FLEAD and FIRST_VALUE handle NULLs and partition edges?","At **partition edges**, `LAG`\u002F`LEAD` return their `default` argument (or `NULL`) —\ne.g. the first row has no previous row. They also return the **actual stored value**\neven if it's `NULL`; they don't skip `NULL` data values.\n\n```sql\n-- first row's prev is 0 (the default), not an error\nLAG(amount, 1, 0) OVER (ORDER BY day)\n```\n\nSome databases (Oracle, and via `IGNORE NULLS` in standard SQL \u002F newer engines)\nsupport `IGNORE NULLS` to skip `NULL` data values and fetch the nearest non-null.\n\nRule of thumb: edges yield the `default`\u002F`NULL`; supply a `default` to clean them\nup, and use `IGNORE NULLS` (where supported) to skip null data.\n",{"id":158,"difficulty":89,"q":159,"a":160},"combining-frames-with-partition","How do frames interact with PARTITION BY?","A frame is always **bounded by the partition** — it never crosses partition\nboundaries. `UNBOUNDED PRECEDING` means the first row **of the current partition**,\nand `LAG`\u002F`LEAD` stop at the partition edge.\n\n```sql\n-- running total resets at each customer; frame stays within the partition\nSELECT customer_id, order_date, amount,\n       SUM(amount) OVER (PARTITION BY customer_id ORDER BY order_date\n                         ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS rt\nFROM orders;\n```\n\nSo per-customer running totals start fresh for each customer automatically.\n\nRule of thumb: frames live inside their partition — `UNBOUNDED` and offsets are\nrelative to the partition, not the whole table.\n",18,null,{"description":74},"SQL window frame interview questions — ROWS vs RANGE, frame boundaries, LAG\u002FLEAD, FIRST_VALUE\u002FLAST_VALUE, moving averages, and default frame gotchas.","sql\u002Fwindow-functions\u002Fframes-and-offsets","Frames & Offset Functions","Window Functions","window-functions","2026-06-20","3jC4V3JmOMKHMW1anUBJYQ68qbQi1ZbUGVcd0ktsBo4",[172,176,179],{"subtopic":173,"path":174,"order":175},"Window Function Basics","\u002Fsql\u002Fwindow-functions\u002Fwindow-basics",1,{"subtopic":177,"path":178,"order":75},"Ranking Functions","\u002Fsql\u002Fwindow-functions\u002Franking-functions",{"subtopic":166,"path":85,"order":84},{"path":181,"title":182},"\u002Fblog\u002Fsql-window-frames-lag-lead","SQL Window Frames, LAG, and LEAD — Moving Averages and Period Comparisons",1782244107058]