SQL Window Functions for Analysts: Ranking, LAG, and Running Totals
SQL window functions for analysts solve a problem that GROUP BY cannot: calculate across related rows while keeping every row in the result. You use them to rank, compare a row to the previous one, build running totals, and keep the latest record per key. This article gives you the patterns, the traps, and the tests.
I have used these functions in hospital reporting, revenue cycle, and finance queries for years. Most defects I found came from three habits: a missing tie-breaker, a default frame, and a filter in the wrong place. Examples use SQL Server syntax. Oracle and Snowflake accept the same core syntax.
A mental model and a function map. Twelve patterns with code. Two defect scenarios with tickets and before/after SQL. A decision tree, a BA specification template, a QA test checklist, platform differences, and a PDF pattern pack.
What SQL Window Functions Do That GROUP BY Cannot
GROUP BY collapses rows. Ten encounters for one department become one row with a count. A window function computes the same count but attaches it to each of the ten rows. You keep the detail and gain the context.
| Need | GROUP BY | Window function |
|---|---|---|
| Total charges per department | One row per department | Total shown beside every encounter |
| Share of department total | Needs a join back to the detail | Divide each row by SUM() OVER |
| Previous admission date | Needs a self-join | LAG() reads the prior row |
| Latest row per patient | Needs a join to a MAX subquery |
ROW_NUMBER() marks it |
| Running balance | Needs a correlated subquery | SUM() OVER (ORDER BY ...) |
Think of the window as a lens over the rows. PARTITION BY splits rows into groups. ORDER BY sorts inside each group. The frame decides which neighboring rows the function sees for the current row.
The anatomy of an OVER clause
SELECT PAT_ENC_CSN_ID,
PAT_ID,
HOSP_ADMSN_TIME,
ROW_NUMBER() OVER (
PARTITION BY PAT_ID -- restart the count for each patient
ORDER BY HOSP_ADMSN_TIME DESC, -- newest first
PAT_ENC_CSN_ID DESC -- tie-breaker, always add one
) AS recency_rank
FROM PAT_ENC_HSP;
Every window function has the same skeleton: a function, then OVER, then an optional partition, an order, and for aggregates an optional frame. Learn the skeleton once and every function reads the same way.
Partition A (patient 1001)
Row 1 | admit Jan 03 | charge 4,200
Row 2 | admit Feb 11 | charge 900 (current row)
Row 3 | admit Mar 29 | charge 1,750
Row 4 | admit Apr 30 | charge 600
Partition B (patient 1002)
Row 1 | admit Jan 15 | charge 3,100
Row 2 | admit Jun 02 | charge 2,300
PARTITION BY pat_id: each patient is its own group.
ORDER BY admit time: rows sort inside the group.
LAG(admit) on Row 2 reads Row 1 (Jan 03).
ROWS BETWEEN 1 PRECEDING AND CURRENT ROW: Rows 1 and 2.
Partitions never see each other. The frame slides with the current row.
Function families
| Family | Functions | Typical analyst use |
|---|---|---|
| Ranking | ROW_NUMBER, RANK, DENSE_RANK, NTILE |
Latest row per key, top N per group, quartiles |
| Offset | LAG, LEAD, FIRST_VALUE, LAST_VALUE |
Days since last visit, change from prior period |
| Aggregate | SUM, AVG, COUNT, MIN, MAX with OVER |
Running totals, moving averages, share of total |
| Distribution | PERCENT_RANK, CUME_DIST, PERCENTILE_CONT |
Percentile of charges or length of stay |
Standard SQL defines these functions, so skills transfer across platforms. The Microsoft Learn OVER clause reference and the Snowflake window function reference list exact syntax and options for each engine.
The Sample Tables Used in These SQL Window Function Examples
Every query uses a small encounter table and a small ledger table. Names follow Epic Clarity where possible, which keeps the examples close to what you query at work. The PAT_ENC table guide describes the real table and its keys.
| Object | Columns used | Grain |
|---|---|---|
PAT_ENC_HSP |
PAT_ENC_CSN_ID, PAT_ID, HOSP_ADMSN_TIME, HOSP_DISCH_TIME, DEPARTMENT_ID |
One row per hospital encounter |
PAT_ENC_DX |
PAT_ENC_CSN_ID, DX_ID, LINE |
One row per diagnosis per encounter |
gl_posting |
account_id, posting_date, posting_id, amount |
One row per ledger posting |
daily_charge |
charge_date, department_id, total_charges |
One row per department per day |
Patient identifiers in these examples stay generic. In real work, apply minimum necessary, report keys instead of names, and keep results inside the approved environment.
SQL Window Functions for Analysts: Twelve Patterns With Code
The patterns below cover most window work I see in analyst queries. Each one states when to use it and the trap that goes with it. All code uses SQL Server syntax and runs with small changes on Oracle and Snowflake.
Order of evaluation: why you cannot filter on a window result
A database applies clauses in a fixed logical order. Window functions run after WHERE, GROUP BY, and HAVING, and before ORDER BY. That order explains the most common error message analysts hit.
| Step | Clause | Consequence for window functions |
|---|---|---|
| 1 | FROM and JOIN |
Rows are assembled |
| 2 | WHERE |
Filters here run before windows, so they change which rows the window sees |
| 3 | GROUP BY and HAVING |
Aggregates exist, so a window can rank them |
| 4 | SELECT windows |
Window values are computed |
| 5 | ORDER BY |
Can use window results |
You cannot write WHERE rn = 1 in the same query level that defines rn. Wrap the window in a CTE or subquery, then filter outside. Snowflake offers QUALIFY to filter in place.
Pattern 1: Keep the latest row per key with ROW_NUMBER
WITH ranked AS (
SELECT h.PAT_ENC_CSN_ID, h.PAT_ID, h.HOSP_ADMSN_TIME,
ROW_NUMBER() OVER (PARTITION BY h.PAT_ID
ORDER BY h.HOSP_ADMSN_TIME DESC, h.PAT_ENC_CSN_ID DESC) AS rn
FROM PAT_ENC_HSP h)
SELECT PAT_ENC_CSN_ID, PAT_ID, HOSP_ADMSN_TIME
FROM ranked
WHERE rn = 1;
Use it for the latest record, the first record, or de-duplication. The tie-breaker after the time column matters. Without it, two encounters with the same admit time return in an arbitrary order, and the “latest” row can change between runs.
Pattern 2: Choose between ROW_NUMBER, RANK, and DENSE_RANK
| Charge | ROW_NUMBER | RANK | DENSE_RANK |
|---|---|---|---|
| 900 | 1 | 1 | 1 |
| 900 | 2 | 1 | 1 |
| 700 | 3 | 3 | 2 |
| 500 | 4 | 4 | 3 |
SELECT encounter_id, total_charges,
ROW_NUMBER() OVER (ORDER BY total_charges DESC) AS row_num,
RANK() OVER (ORDER BY total_charges DESC) AS rnk,
DENSE_RANK() OVER (ORDER BY total_charges DESC) AS dense_rnk
FROM encounter_charges;
ROW_NUMBER never ties, so it picks one row. RANK ties and then skips numbers. DENSE_RANK ties and does not skip. For “top 3 values” use DENSE_RANK. For “exactly one row per key” use ROW_NUMBER.
Pattern 3: Top N per group
WITH dx_counts AS (
SELECT h.DEPARTMENT_ID, dx.DX_ID, COUNT(*) AS encounters,
DENSE_RANK() OVER (PARTITION BY h.DEPARTMENT_ID ORDER BY COUNT(*) DESC) AS r
FROM PAT_ENC_HSP h
JOIN PAT_ENC_DX dx ON dx.PAT_ENC_CSN_ID = h.PAT_ENC_CSN_ID AND dx.LINE = 1
WHERE h.HOSP_DISCH_TIME >= '2026-07-01' AND h.HOSP_DISCH_TIME < '2026-10-01'
GROUP BY h.DEPARTMENT_ID, dx.DX_ID)
SELECT DEPARTMENT_ID, DX_ID, encounters, r
FROM dx_counts
WHERE r <= 3
ORDER BY DEPARTMENT_ID, r;
The window ranks the aggregate in the same query, because windows run after GROUP BY. Ties can return more than three rows per department. Decide whether the business wants ties kept, then pick DENSE_RANK or ROW_NUMBER to match.
Pattern 4: Compare to the previous row with LAG
WITH e AS (
SELECT PAT_ID, PAT_ENC_CSN_ID, HOSP_ADMSN_TIME, HOSP_DISCH_TIME,
LAG(HOSP_DISCH_TIME) OVER (PARTITION BY PAT_ID
ORDER BY HOSP_ADMSN_TIME, PAT_ENC_CSN_ID) AS prior_disch
FROM PAT_ENC_HSP)
SELECT PAT_ID, PAT_ENC_CSN_ID, prior_disch, HOSP_ADMSN_TIME,
DATEDIFF(day, prior_disch, HOSP_ADMSN_TIME) AS days_since_discharge
FROM e
WHERE prior_disch IS NOT NULL
AND DATEDIFF(day, prior_disch, HOSP_ADMSN_TIME) BETWEEN 0 AND 30;
This is an operational flag, not the federal measure. The CMS readmission measures apply exclusions, such as planned readmissions and transfers, that this query ignores. Label the output “return within 30 days” unless the business has signed a full definition. For day-count rules, see the length of stay calculation article.
LAG and LEAD take an offset and a default: LAG(col, 1, 0). The first row in each partition has no previous row, so it returns null unless you supply a default. Test that row.
Pattern 5: Running totals with an explicit frame
SELECT account_id, posting_date, posting_id, amount,
SUM(amount) OVER (PARTITION BY account_id
ORDER BY posting_date, posting_id
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_balance
FROM gl_posting;
Write the frame every time. With ORDER BY and no frame, the default is RANGE from the start to the current row. RANGE treats rows with equal order values as peers and gives them the same total. Two postings on one date then show an identical balance, and the sequence looks wrong. Scenario two below shows the effect. Ledger patterns appear in SQL for financial reporting.
Pattern 6: Moving averages
SELECT department_id, charge_date, total_charges,
AVG(total_charges) OVER (PARTITION BY department_id
ORDER BY charge_date
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS avg_7_rows
FROM daily_charge;
ROWS counts rows, not days. If a department has no row for a Sunday, the “seven day” average spans eight calendar days. Join to a calendar table first so every date exists, with zero for quiet days. The first six rows average fewer than seven values, so mark them as incomplete in the report.
Pattern 7: Share of total
SELECT department_id, charge_date, total_charges,
100.0 * total_charges / NULLIF(SUM(total_charges) OVER (PARTITION BY charge_date), 0) AS pct_of_day,
100.0 * total_charges / NULLIF(SUM(total_charges) OVER (), 0) AS pct_of_all
FROM daily_charge;
An empty OVER () means “all rows in the result”. It sees only the rows that survive WHERE. If a filter removes departments, the percentages sum to 100 over the filtered set, not over the organization.
Pattern 8: Period-over-period change
WITH m AS (
SELECT DATEFROMPARTS(YEAR(charge_date), MONTH(charge_date), 1) AS month_start,
SUM(total_charges) AS total_charges
FROM daily_charge GROUP BY DATEFROMPARTS(YEAR(charge_date), MONTH(charge_date), 1))
SELECT month_start, total_charges,
LAG(total_charges) OVER (ORDER BY month_start) AS prior_month,
100.0 * (total_charges - LAG(total_charges) OVER (ORDER BY month_start))
/ NULLIF(LAG(total_charges) OVER (ORDER BY month_start), 0) AS pct_change
FROM m;
Aggregate first, then apply LAG. Window functions on raw daily rows give a day-over-day change instead. A year-over-year view uses LAG(x, 12) on a complete monthly series, and breaks when a month is missing.
Pattern 9: Gaps and islands
SELECT department_id, MIN(charge_date) AS island_start, MAX(charge_date) AS island_end, COUNT(*) AS days_in_run
FROM (SELECT department_id, charge_date,
DATEADD(day, -ROW_NUMBER() OVER (PARTITION BY department_id ORDER BY charge_date), charge_date) AS grp
FROM daily_charge) x
GROUP BY department_id, grp
ORDER BY department_id, island_start;
Subtracting the row number from the date gives the same value for each unbroken run, so grouping on it finds the runs. Use it to find stretches of daily activity or the days a feed went quiet. The date must be unique within each partition. Duplicates break the arithmetic.
Pattern 10: Buckets and percentiles
SELECT encounter_id, los_days,
NTILE(4) OVER (ORDER BY los_days) AS quartile,
PERCENT_RANK() OVER (ORDER BY los_days) AS pct_rank,
PERCENTILE_CONT(0.9) WITHIN GROUP (ORDER BY los_days) OVER (PARTITION BY department_id) AS p90_los
FROM encounter_los;
NTILE forces equal-sized buckets, so equal values can land in different buckets. If the business needs “everyone with the same value gets the same bucket”, use PERCENT_RANK and cut on it. Report the 90th percentile next to the average for skewed measures such as length of stay.
Pattern 11: FIRST_VALUE and LAST_VALUE
SELECT PAT_ID, HOSP_ADMSN_TIME,
FIRST_VALUE(DEPARTMENT_ID) OVER (PARTITION BY PAT_ID ORDER BY HOSP_ADMSN_TIME, PAT_ENC_CSN_ID) AS first_dept,
LAST_VALUE(DEPARTMENT_ID) OVER (PARTITION BY PAT_ID ORDER BY HOSP_ADMSN_TIME, PAT_ENC_CSN_ID
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS last_dept
FROM PAT_ENC_HSP;
LAST_VALUE without the full frame returns the current row, because the default frame ends there. Analysts hit this often and conclude the function is broken. Set the frame to unbounded following.
Pattern 12: De-duplicate staging data
WITH d AS (
SELECT *, ROW_NUMBER() OVER (PARTITION BY encounter_csn ORDER BY loaded_at DESC, load_id DESC) AS rn
FROM stg.encounter)
DELETE FROM d WHERE rn > 1;
Run a SELECT with WHERE rn > 1 first and review the rows. Take a backup or use a transaction. Oracle and Snowflake do not allow deleting through a CTE, so they need a keyed delete or a rebuilt table.
Scenario: The Follow-Up Call List That Changed Every Morning
A health system built a daily list of patients due a post-discharge follow-up call. The query kept each patient’s latest encounter with ROW_NUMBER. Nurses noticed that some patients appeared one day, vanished the next, and returned later.
I was the QA analyst on the project. The load was stable and the source data had not changed. I ran the list query twice on the same snapshot and compared the keys with EXCEPT. The two runs disagreed.
Before
WITH ranked AS (
SELECT PAT_ID, PAT_ENC_CSN_ID, HOSP_ADMSN_TIME,
ROW_NUMBER() OVER (PARTITION BY PAT_ID ORDER BY HOSP_ADMSN_TIME DESC) AS rn
FROM PAT_ENC_HSP)
SELECT PAT_ID, PAT_ENC_CSN_ID FROM ranked WHERE rn = 1;
After, with the checks that prove it
-- Fix: deterministic order
WITH ranked AS (
SELECT PAT_ID, PAT_ENC_CSN_ID, HOSP_ADMSN_TIME,
ROW_NUMBER() OVER (PARTITION BY PAT_ID
ORDER BY HOSP_ADMSN_TIME DESC, PAT_ENC_CSN_ID DESC) AS rn
FROM PAT_ENC_HSP)
SELECT PAT_ID, PAT_ENC_CSN_ID FROM ranked WHERE rn = 1;
-- Check 1: how many patients have tied admit times?
SELECT PAT_ID, HOSP_ADMSN_TIME, COUNT(*) AS encounters
FROM PAT_ENC_HSP
GROUP BY PAT_ID, HOSP_ADMSN_TIME
HAVING COUNT(*) > 1;
-- Check 2: run the fixed query into two temp tables, then compare
SELECT * FROM #run1 EXCEPT SELECT * FROM #run2; -- expect zero rows
SELECT * FROM #run2 EXCEPT SELECT * FROM #run1; -- expect zero rows
| Test | Before fix | After fix |
|---|---|---|
| Run twice, compare with EXCEPT | 212 differing keys | 0 differing keys |
| Patients with tied admit times | 212 | 212 (data unchanged, result now stable) |
| Follow-up calls sent to the wrong visit | 212 patients | 0 |
| Repeatability test in regression pack | None | Added |
The data was never wrong. The query left a decision to the optimizer. Any window function whose order key is not unique can behave this way, including LAG, FIRST_VALUE, and running totals. Treat a unique sort key as a requirement.
The health system, ticket number, and counts are a composite from reporting projects. The failure, a non-unique order key, is common in encounter, claim, and ledger data.
Scenario: A Running Balance That Skipped a Step
A finance team reviewed an account roll-forward built with a running total. A reviewer flagged that two ledger rows showed the same balance and asked whether a posting was missing. No posting was missing. The query used the default window frame.
| Date | Posting | Amount | Default frame (RANGE) | Explicit ROWS frame |
|---|---|---|---|---|
| Mar 1 | 101 | 500 | 500 | 500 |
| Mar 2 | 102 | 1,000 | 1,100 | 1,500 |
| Mar 2 | 103 | -400 | 1,100 | 1,100 |
| Mar 3 | 104 | 200 | 1,300 | 1,300 |
-- Before: default frame, ties share a total
SUM(amount) OVER (PARTITION BY account_id ORDER BY posting_date)
-- After: unique order, explicit frame
SUM(amount) OVER (PARTITION BY account_id
ORDER BY posting_date, posting_id
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)
The end balance was the same in both versions, so a tie-out to the trial balance passed. Only the intermediate rows were wrong. When people read the detail, intermediate values matter, so test them as well as the final row.
Choosing SQL Window Functions for Analysts: A Decision Tree
Start from the question the report must answer. The tree below picks the function family. It ends in one rule that applies to every branch: make the sort order unique.
What must the report show?
Compare to aneighbor row
Rank or pick rowsper group
Cumulative ormoving value
Share of a wholeor % of total
LAG / LEADTest the first row
Ties kept: RANK orDENSE_RANKOne row only: ROW_NUMBER
SUM / AVG OVERwith a ROWS frame
SUM(x) OVER(PARTITION BY …)
Every branch: add a tie-breaker so the ORDER BY is unique
Specification Template for Window Function Requirements
A requirement that says “show the latest encounter” invites the defect in scenario one. Write the six decisions below into the story or the data mapping before development starts.
| Field | What to write | Example |
|---|---|---|
| Business question | One sentence in business words | Which encounter gets the follow-up call? |
| Output grain | One row per what | One row per patient |
| Partition key | The group that restarts the calculation | Patient ID |
| Order key and direction | Business meaning of the order | Admit time, newest first |
| Tie-breaker | What decides equal order values | Higher encounter key wins |
| Frame | Rows or range, and how many | Not applicable; or all prior rows |
| Null and first-row rule | What the first row and null values show | No prior visit shows blank |
| Sample result | Three hand-checked rows | Attach to the ticket |
| Sign-off | Who approved the definition | Name and date |
QA test checklist for window function queries
| Test | Row to build | Expected result |
|---|---|---|
| Tied order values | Two rows with the same order key in one partition | Same output on every run |
| Single-row partition | A patient with one encounter | No error; LAG returns null or default |
| First and last row | Earliest and latest rows in a partition | Edge values as specified |
| Null order key | A row with a null sort column | Placed where the platform and spec say |
| Frame boundaries | Rows near the start of the partition | Moving average marked incomplete |
| Missing dates | A gap in a daily series | Calendar fill or documented exclusion |
| Filter placement | Same query with the filter before and after the window | Results match the specification |
| Repeatability | Run twice and compare with EXCEPT | Zero differing rows |
Use the repeatability test from scenario one in your regression pack. The SQL Server vs Oracle vs Snowflake article lists the platform differences that make a window query pass on one engine and fail on another.
A Worked Walkthrough: Reading Window Output Row by Row
Build a seed set small enough to check by hand before you trust a query on millions of rows. One patient with four encounters is enough. Two of them share an admit date, which tests the tie-breaker. The table shows each function over the same rows, ordered by admit time and then encounter key.
| Encounter | Admit | Discharge | Charge | ROW_NUMBER (newest first) | LAG(discharge) | Days since prior discharge | Running charges |
|---|---|---|---|---|---|---|---|
| 9001 | Jan 03 | Jan 08 | 4,200 | 4 | none | none | 4,200 |
| 9002 | Feb 11 | Feb 14 | 900 | 3 | Jan 08 | 34 | 5,100 |
| 9003 | Feb 11 | Feb 12 | 650 | 2 | Feb 14 | -3 | 5,750 |
| 9004 | Mar 05 | Mar 09 | 1,750 | 1 | Feb 12 | 21 | 7,500 |
Read the table from the top. The first row has no prior discharge, so LAG returns null and the day count is blank. Encounter 9004 gets row number 1, so the latest-encounter pattern keeps it.
Row 9003 shows minus three days. It was admitted on Feb 11, before the prior encounter ended on Feb 14. Overlapping encounters happen when an emergency visit converts to an inpatient stay or when registration creates a second record. A BETWEEN 0 AND 30 filter drops this row from a readmission list, which may or may not match the business rule. Raise the case with the data steward and write the answer into the specification.
Keep this seed set in version control beside the query. After every change, run the query against it and compare the output with the table above. A mismatch in any column points to a specific clause: the partition, the order, the tie-breaker, or the frame.
Platform Differences for Window Functions
The core functions behave the same on SQL Server, Oracle, and Snowflake. The edges differ, and the edges cause ports to fail. Check these rows before you move a query.
| Behavior | SQL Server | Oracle | Snowflake |
|---|---|---|---|
| Nulls in ascending order | Sort first | Sort last | Sort last by default |
NULLS FIRST / NULLS LAST |
Not supported; use a CASE sort key |
Supported | Supported |
| Filter on a window result in place | No; wrap in CTE | No; wrap in subquery | QUALIFY clause |
Default frame with ORDER BY |
RANGE to current row |
RANGE to current row |
RANGE to current row |
Value-based RANGE offsets |
Unbounded and current row only | Supported | Check the reference for your version |
| Distinct inside a window aggregate | Not allowed | Limited; check version | Check the reference for your version |
| Delete through a CTE | Allowed | Not allowed | Not allowed |
The null-ordering row catches the most people. A “latest” query that sorts a nullable column descending puts nulls first on SQL Server and last on Oracle. Add an explicit rule or filter nulls out. The SQL Server vs Oracle vs Snowflake article covers more differences in dates, text, and cost.
-- Snowflake: QUALIFY filters on the window directly
SELECT PAT_ID, PAT_ENC_CSN_ID
FROM PAT_ENC_HSP
QUALIFY ROW_NUMBER() OVER (PARTITION BY PAT_ID ORDER BY HOSP_ADMSN_TIME DESC, PAT_ENC_CSN_ID DESC) = 1;
-- SQL Server: control null position with a CASE sort key
ORDER BY CASE WHEN HOSP_DISCH_TIME IS NULL THEN 1 ELSE 0 END, HOSP_DISCH_TIME DESC
Performance of SQL Window Functions on Large Tables
A window function needs rows grouped and sorted by its partition and order columns. That sort is usually the cost. On a table of fifty million claim lines, an unfiltered window can run for minutes or spill to disk.
| Technique | Why it helps | Platform note |
|---|---|---|
| Filter rows before the window | Fewer rows to sort | All platforms; put date filters in WHERE |
| Index on partition then order columns | The engine can read rows already sorted | SQL Server and Oracle; Snowflake has no indexes |
| Reuse one partition and order across functions | One sort serves several windows | All platforms |
| Select only needed columns | Smaller rows sort and spill less | All platforms |
| Pre-aggregate before windowing | Window runs over thousands of rows, not millions | All platforms |
| Read the execution plan | Shows sort and spill operators | SQL Server plan, Oracle plan, Snowflake Query Profile |
Compare a window against the alternative before you assume it is faster. A window usually beats a self-join or a correlated subquery, because it scans the data once. A window over an unfiltered table can still lose to a narrow aggregate plus a join. Measure on production-sized data.
Role Checklists for Window Function Work
Business Analyst
- Fill the specification template: grain, partition, order, tie-breaker, frame.
- Define what “latest”, “previous”, and “first” mean in business words.
- Attach three hand-checked sample rows to every story.
- Record who approved the definition.
QA Analyst
- Run each query twice and compare with
EXCEPT. - Build rows for ties, nulls, single-row partitions, and missing dates.
- Check intermediate rows of a running total, not only the last one.
- Log the platform and version in every defect.
Data Analyst or Developer
- End every window
ORDER BYwith a unique column. - Write the frame clause for every ordered aggregate.
- Filter in an outer query or with
QUALIFY. - Check the plan for sorts and spills on large tables.
Common Mistakes With SQL Window Functions
| Mistake | Effect | Fix |
|---|---|---|
| Order key is not unique | Different rows on repeated runs | Add a unique tie-breaker |
| Default frame on a running total | Equal dates share one balance | Use ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW |
LAST_VALUE with default frame |
Returns the current row | Extend the frame to unbounded following |
Filtering the window in WHERE |
Error, or wrong rows if filtered earlier | Wrap in a CTE, or use QUALIFY |
Moving average with ROWS over gappy dates |
Window spans more days than named | Join a calendar table first |
| Percent of total after a filter | Shares sum over the filtered set | Compute the total in a separate unfiltered step |
| Ignoring null order keys | Rows land first or last by platform | State the rule and test it |
Using NTILE for business tiers |
Equal values split across buckets | Cut on a rank or a value threshold |
I shipped a running total that passed the month-end tie-out. A reviewer then read the detail and saw repeated balances. The last row was right and the middle rows were not. I now check intermediate rows as well as totals.
When I Would Use Window Functions, GROUP BY, or Something Else
| Situation | My choice | Why |
|---|---|---|
| Need totals only, one row per group | GROUP BY |
Simpler, faster, and clearer |
| Need detail rows plus a group figure | Window aggregate | No join back to the detail |
| Need previous or next row values | LAG / LEAD |
Replaces a self-join |
| Need one row per key | ROW_NUMBER in a CTE |
Deterministic with a tie-breaker |
| Need a pivot-style layout for a report | BI tool or PIVOT |
Presentation, not calculation |
| Need row-by-row procedural logic | Set-based rewrite first, loop last | Loops scale poorly on large tables |
I reach for a window function when the answer needs both the row and its context. When the answer is a single number per group, I use GROUP BY and move on. For more query patterns in this series, see the SQL for IT Analysts hub.
Edge Cases That Break Clean Window Queries
| Edge case | Why it breaks the query | How to handle it |
|---|---|---|
| Duplicate source rows | Window counts the duplicates | De-duplicate first with Pattern 12 |
| Late-arriving rows | Rank and running totals change after the fact | Version the report or state a cutoff |
| Time zones and daylight saving | Order by local time can repeat an hour | Order by UTC |
| Merged patient records | Partition by patient splits or joins histories | Partition by the surviving ID after the merge |
| Corrected and reversed postings | Running totals include both rows | Order by posting ID and test reversals |
| Very large partitions | One patient or account holds millions of rows | Filter the date range before the window |
| Floating point amounts | Running totals drift | Use fixed decimals for money |
| Cross-database ports | Null order and frame defaults differ | Write order and frame explicitly |
Window results can reveal individual patient histories. Keep outputs inside the approved environment, share keys and aggregates instead of names, and apply minimum necessary to any extract.
Questions Analysts Ask About SQL Window Functions
Is a window function faster than a self-join?
Usually, because it reads the data once and sorts once. A self-join reads the table twice. Test both on production-sized data, because indexes and filters change the result.
Can I use a window function in WHERE or GROUP BY?
No. The database computes windows after those clauses. Put the window in a CTE or subquery, then filter in the outer query. Snowflake also accepts QUALIFY.
What is the difference between PARTITION BY and GROUP BY?
GROUP BY reduces the result to one row per group. PARTITION BY only defines the group for the calculation and leaves every row in place.
Why does my running total repeat values?
You used the default RANGE frame and the order key has duplicates. Add a unique tie-breaker and write ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW.
Run This Before Your Next Release
Pick one report that uses ROW_NUMBER, LAG, or a running total. Check whether its ORDER BY ends in a unique column. Run the query twice on the same snapshot and compare the results with EXCEPT. If any row differs, you have found a defect before your users did.
Sources and Further Reading
- Microsoft Learn OVER clause reference. Official syntax for partitions, ordering, and frames in T-SQL.
- Snowflake window function reference. Official list of Snowflake window functions, including
QUALIFYusage. - Oracle Database SQL Language Reference, analytic functions chapter (docs.oracle.com). Official syntax for Oracle.
- CMS Hospital Readmissions Reduction Program documentation (cms.gov). Official measure definitions and exclusions for readmission reporting.
Table and column names are generic or follow standard Clarity and vary by version. Figures in scenarios are illustrative. Run every query in a non-production environment first.
