{"post":{"slug":"sql-window-functions-2026","title":"SQL window functions in 2026: the complete practical guide","excerpt":"Window functions are the most powerful SQL feature most developers underuse. Here is a concrete 2026 guide to rankings, running totals, and time-series gaps.","category":"code","date":"March 3, 2026","author":"ByteLedger Team","content":"<span class=\"dropcap\">W</span>indow functions are the feature that separates developers who fight with SQL from developers who make it sing. The moment you stop reaching for a self-join or a correlated subquery to compare a row to its neighbours, and start writing `OVER (PARTITION BY ... ORDER BY ...)`, your queries become shorter, faster, and readable. This guide covers everything you need in 2026.\n\n## What changed in 2026\n\n- **DuckDB 1.x is now everywhere** for analytical workloads, and its window function support is first-class — including `EXCLUDE` frame clauses that Postgres only got in version 14.\n- **Postgres 16 added incremental sort improvements** that make large window partitions significantly faster without explicit index hints.\n- **MySQL 8.4 closed the last gaps** in its window function coverage, so the excuse \"we're on MySQL\" no longer applies.\n- **SQL:2023's pattern-matching extensions** (MATCH_RECOGNIZE) are arriving in enterprise databases, but the core window spec is stable and universal.\n\n## The mental model\n\nA window function runs *after* the WHERE and GROUP BY phases, but *before* ORDER BY and LIMIT. Every output row still exists; the function just gets to look sideways at related rows inside its \"window.\"\n\n```sql\nSELECT\n  order_id,\n  customer_id,\n  amount,\n  SUM(amount) OVER (PARTITION BY customer_id ORDER BY order_date\n                    ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_total\nFROM orders;\n```\n\nThe `OVER` clause defines three things: the partition (scope), the order (how rows are sequenced inside the window), and the frame (which rows count — defaults vary by function).\n\n## Ranking functions\n\n| Function | Ties | Gaps after tie |\n| --- | --- | --- |\n| ROW_NUMBER() | Arbitrary | n/a |\n| RANK() | Same rank | Yes (1,1,3) |\n| DENSE_RANK() | Same rank | No (1,1,2) |\n| NTILE(n) | Bucketed | n/a |\n| PERCENT_RANK() | 0.0–1.0 ratio | — |\n\n```sql\n-- Rank employees by salary within each department\nSELECT\n  name,\n  department,\n  salary,\n  RANK()       OVER (PARTITION BY department ORDER BY salary DESC) AS rnk,\n  DENSE_RANK() OVER (PARTITION BY department ORDER BY salary DESC) AS dense_rnk\nFROM employees;\n```\n\nUse `DENSE_RANK` when you want to avoid gap numbers. Use `ROW_NUMBER` when you need a unique sequential ID regardless of ties.\n\n## LAG and LEAD: comparing to neighbours\n\n`LAG(col, offset, default)` looks *back*; `LEAD(col, offset, default)` looks *forward*.\n\n```sql\n-- Month-over-month revenue change\nSELECT\n  month,\n  revenue,\n  LAG(revenue, 1, 0) OVER (ORDER BY month)        AS prev_month,\n  revenue - LAG(revenue, 1, 0) OVER (ORDER BY month) AS change\nFROM monthly_revenue;\n```\n\nThe third argument (default) prevents NULLs on the first/last row — always supply it.\n\n## Frame clauses\n\nWithout an explicit frame, functions that require ORDER BY default to `RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW`. For `ROW_NUMBER` and `RANK`, no frame applies. Knowing this prevents subtle bugs in running aggregates.\n\n```sql\n-- 7-day rolling average (row-based frame, not range-based)\nSELECT\n  event_date,\n  value,\n  AVG(value) OVER (\n    ORDER BY event_date\n    ROWS BETWEEN 6 PRECEDING AND CURRENT ROW\n  ) AS rolling_7d_avg\nFROM daily_metrics;\n```\n\n`ROWS` counts literal rows. `RANGE` groups rows with equal ORDER BY values. Use `ROWS` for time-series work where dates may repeat.\n\n## What changed in 2026\n\n## Running totals and deduplication\n\n```sql\n-- Deduplicate: keep only the latest row per user\nWITH ranked AS (\n  SELECT *,\n    ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY updated_at DESC) AS rn\n  FROM user_events\n)\nSELECT * FROM ranked WHERE rn = 1;\n```\n\nThis pattern replaces the classic \"MAX in a subquery\" approach and performs better on indexed tables.\n\n## Comparison table: window vs GROUP BY\n\n| Need | Use |\n| --- | --- |\n| Total per group, one row per group | GROUP BY + aggregate |\n| Total per group, one row *per original row* | Window function |\n| Compare each row to its group average | Window function |\n| Top-N per group | ROW_NUMBER() window + WHERE rn <= N |\n| Percent of total within group | SUM(x) OVER (PARTITION BY g) |\n\n## How to pick the right function\n\n1. **Need a rank or position within a group?** → `RANK`, `DENSE_RANK`, `ROW_NUMBER`.\n2. **Need to compare to the previous or next row?** → `LAG` / `LEAD`.\n3. **Need a running aggregate (sum, avg)?** → `SUM`/`AVG` with `ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW`.\n4. **Need a fixed aggregate per partition without collapsing?** → `SUM(x) OVER (PARTITION BY g)` with no ORDER BY.\n5. **Need to bucket rows evenly?** → `NTILE(n)`.\n\n## Common mistakes\n\n**Confusing RANGE and ROWS.** If your ORDER BY column has duplicates (e.g., two rows on the same date), `RANGE` will include both in each row's frame — doubling counts. Use `ROWS` for precise control.\n\n**Forgetting to supply a default to LAG/LEAD.** The NULL on the boundary row often propagates into calculations silently.\n\n**Using window functions in WHERE.** Window functions are computed after WHERE filtering, so you cannot filter on them directly. Wrap in a CTE or subquery first.\n\n**Over-partitioning.** A partition with one row produces the same result as no partition for most functions — verify your PARTITION BY key actually groups meaningfully.\n\n## What to skip\n\n- **Correlated subqueries as a substitute** — `SELECT *, (SELECT AVG(x) FROM t t2 WHERE t2.dept = t.dept)` is almost always slower than a window aggregate.\n- **Application-side rolling calculations** — pulling raw rows into Python/JS to compute rolling averages you could compute in SQL wastes a round-trip.\n- **OVER() with no PARTITION BY for large tables** — a single global partition forces a full sort; verify you actually need the global window.\n\n## FAQ\n\n**Do window functions work with CTEs?**\nYes, and that's the recommended pattern — define your base query in a CTE, then apply window functions in the outer SELECT, or vice versa.\n\n**Are window functions slower than GROUP BY?**\nFor the same operation, they're comparable. The difference is that window functions keep all rows, so the result set can be larger. Indexes on PARTITION BY + ORDER BY columns help significantly.\n\n**Can I use multiple window functions in one query?**\nYes. The planner typically shares the sort step if the windows have the same PARTITION BY and ORDER BY, so it's cheaper than it looks.\n\n**What is FILTER (WHERE ...) in a window?**\nIt's a SQL:2003 clause supported in Postgres and DuckDB that lets you conditionally include rows in an aggregate window: `SUM(amount) FILTER (WHERE status = 'paid') OVER (PARTITION BY customer_id)`.\n\n## Where to go next\n\nSee [Database indexing explained in 2026](/blog/database-indexing-explained-2026) for making window queries fast at scale, and [Caching strategies in 2026](/blog/caching-strategies-2026) for when to pre-aggregate results rather than compute them on every request.","hue":312,"metaTitle":"SQL Window Functions 2026: Complete Practical Guide","metaDescription":"Master SQL window functions in 2026. Rankings, running totals, LAG/LEAD, and frame clauses explained with real examples for Postgres, MySQL, and DuckDB.","keywords":["sql window functions 2026","sql over clause explained","partition by sql","rank vs dense_rank sql","lag lead sql examples","running total sql","sql frame clause","window function postgres 2026","sql analytics functions","sql window function tutorial"],"tldr":["**Window functions compute across related rows without collapsing them** — you keep every row and still get aggregates, rankings, and offsets.","**PARTITION BY is the key mental model** — it scopes the window the same way GROUP BY scopes an aggregate, but without shrinking the result set.","**ROW_NUMBER, RANK, DENSE_RANK, LAG, LEAD, and SUM/OVER cover 90% of real use cases** — master these before exploring the rest.","**Modern databases (Postgres 16, MySQL 8.4, DuckDB 1.x) all support the full SQL:2023 window spec** — no excuses in 2026.","**Skip:** rewriting window queries as correlated subqueries for \"performance\" — the planner almost always handles OVER clauses better."],"readTime":"5 min read","wordCount":1101},"related":[{"slug":"what-is-a-function-2026","title":"What is a function in programming in 2026? A clear guide","excerpt":"A function is a named, reusable block of code that takes inputs and returns a result. This guide explains how functions work, why they matter, and how to write a good one.","category":"code","date":"June 1, 2026","author":"ByteLedger Team","readTime":"4 min read","hue":142},{"slug":"how-to-write-a-sql-query-2026","title":"How to write a SQL query in 2026","excerpt":"SQL is 50 years old and still the fastest path from question to answer in most databases. This guide covers SELECT fundamentals, joins, aggregations, and modern window functions.","category":"code","date":"March 18, 2026","author":"ByteLedger Team","readTime":"5 min read","hue":315},{"slug":"what-is-a-higher-order-function-2026","title":"What Is a Higher-Order Function? 2026 Guide","excerpt":"A higher-order function takes a function as an argument, returns one, or both. Here is why that one capability underpins map, filter, reduce, and most functional-style code.","category":"code","date":"July 18, 2026","author":"ByteLedger Team","readTime":"4 min read","hue":200}],"sameCategory":[{"slug":"bazel-build-explained-2026","title":"Bazel Explained: What Hermetic Builds Actually Cost","excerpt":"Bazel delivers reproducible, precisely cached builds and demands that you describe everything. Here is what you get, what it costs, and who should say no.","category":"code","date":"August 19, 2026","author":"ByteLedger Team","readTime":"3 min read","hue":145},{"slug":"graphql-federation-2026","title":"GraphQL Federation: One Graph, Many Teams","excerpt":"Federation composes independently-owned subgraphs into a single API. Here is what it solves, the operational cost it adds, and when a simpler gateway is enough.","category":"code","date":"August 19, 2026","author":"ByteLedger Team","readTime":"3 min read","hue":300},{"slug":"hermetic-builds-explained-2026","title":"Hermetic Builds Explained: Same Inputs, Same Output","excerpt":"A hermetic build depends only on declared inputs. Here is what that property gives you, the common leaks that break it, and how to move toward it incrementally.","category":"code","date":"August 19, 2026","author":"ByteLedger Team","readTime":"3 min read","hue":130},{"slug":"monorepo-tooling-compared-2026","title":"Monorepo Tooling Compared: What You Actually Need","excerpt":"Monorepo tools solve task orchestration and caching, not repository layout. Here is what each tier provides and how to tell when you have outgrown the simple option.","category":"code","date":"August 19, 2026","author":"ByteLedger Team","readTime":"3 min read","hue":150}],"newer":{"slug":"spring-vs-quarkus-2026","title":"Spring vs Quarkus in 2026: choosing the right Java backend framework","excerpt":"Spring Boot is the enterprise Java default; Quarkus is the cloud-native challenger with native compilation. Here is the honest 2026 tradeoff guide.","category":"code","date":"March 3, 2026","author":"ByteLedger Team","readTime":"4 min read","hue":313},"older":{"slug":"swift-vs-kotlin-2026","title":"Swift vs Kotlin in 2026: which language is right for your mobile project?","excerpt":"Swift and Kotlin have converged in syntax but still diverge where it matters. Here is the honest 2026 comparison for developers choosing a mobile language.","category":"code","date":"March 3, 2026","author":"ByteLedger Team","readTime":"5 min read","hue":307}}