SQL Window Functions skill for Codex
Rankings and running totals that are actually right. Codex writes window queries with explicit partitions, deterministic tiebreakers and stated frames, so LAST_VALUE and running totals stop returning quietly wrong numbers. It covers latest-row dedupe, top N per group, period-over-period with date spines, streaks, sessionization and recursive CTEs, adapts syntax per dialect including QUALIFY, and proves results on a small fixture with ties and gaps.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- sql-window-functions Installs to ~/.agents/skills/sql-window-functions/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install sql-window-functions The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project sql-window-functions Installs to .agents/skills/sql-window-functions/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/sql-window-functions.zipunzip -o sql-window-functions.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) sql-window-functions PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $sql-window-functions, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Writes correct, fast SQL window functions and CTEs across PostgreSQL, MySQL 8, SQLite, SQL Server, BigQuery, Snowflake and DuckDB - ROW_NUMBER, RANK, LAG and LEAD, running and moving aggregates, explicit ROWS vs RANGE frames, top-N per group, deduplication, gaps-and-islands, sessionization and recursive CTEs, with QUALIFY where the dialect supports it. Use when a query needs rankings, running totals, period-over-period changes, latest row per key, streaks, or when someone asks to replace a self-join or correlated subquery with a window.
Use it when
- A query needs ranking, running totals or comparisons with previous rows
- Deduplicating to the latest row per key
- Finding streaks, sessions or contiguous ranges
- Replacing a slow self-join or correlated subquery
Use something else when
- The question is broader analytics SQL for a warehouse or BI metric: use sql-for-analytics
- You need a review of an existing query's safety and correctness: use sql-query-review
How to check the result
A small VALUES fixture with ties, NULLs and missing periods returns the hand-computed answer, and totals on real data reconcile with an equivalent GROUP BY query.
The mistake it prevents
Relying on the default RANGE frame, so LAST_VALUE returns the current row and running totals jump for rows with equal timestamps.
A window query is right when its partitions, ordering and frame are all stated explicitly, ties are broken deterministically, and the result was checked against a small hand-computed sample. Most window bugs are silent: the query runs and returns plausible numbers that are wrong.
Before writing
- Identify the dialect (
AGENTS.md, connection config, ORM) and version. QUALIFY, RANGE with interval offsets, GROUPS frames, FILTER andIGNORE NULLSvary by engine. - State the grain in a sentence: “one row per user per day”, “one row per order”. Window functions do not change row count; if the output grain differs from the input, you need GROUP BY or a filter as well.
- Build a 10-20 row fixture (a
VALUESCTE) with ties, NULLs, gaps and partition boundaries, and know the expected output before running.
Anatomy and evaluation order
fn(args) OVER (
PARTITION BY k -- independent groups
ORDER BY t, id -- order inside each group; add a unique tiebreaker
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW -- frame, for aggregates and value functions
)
Windows are computed after WHERE, GROUP BY and HAVING, before ORDER BY and LIMIT. So
you cannot filter on a window result in WHERE: wrap it in a CTE/subquery, or use
QUALIFY (BigQuery, Snowflake, DuckDB, Databricks, Teradata; not Postgres, MySQL,
SQL Server or SQLite).
The default frame trap
With ORDER BY and no frame clause, the frame is
RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. Two consequences:
LAST_VALUE(x) OVER (PARTITION BY k ORDER BY t)returns the current row’s value, not the partition’s last. UseROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING, orFIRST_VALUEwith descending order.- Running totals with
RANGEtreat rows with equalORDER BYvalues as peers and give them all the same, already-summed total. UseROWSfor a row-by-row running sum.
Always write the frame explicitly for aggregates over windows.
Recipes
Latest row per key (dedupe)
WITH ranked AS (
SELECT *, ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY updated_at DESC, id DESC) AS rn
FROM customer_snapshots
)
SELECT * FROM ranked WHERE rn = 1;
Postgres alternative: SELECT DISTINCT ON (customer_id) * FROM customer_snapshots ORDER BY customer_id, updated_at DESC, id DESC;
QUALIFY dialects: ... QUALIFY ROW_NUMBER() OVER (...) = 1.
Top N per group
ROW_NUMBER gives exactly N; RANK includes ties (may return more than N, with gaps);
DENSE_RANK returns the top N distinct values. Pick deliberately and say which.
For large tables in Postgres, a LATERAL (... ORDER BY ... LIMIT N) join backed by an
index on (group_key, sort_key) can beat a window over the whole table.
Period over period
SELECT month, revenue,
revenue - LAG(revenue) OVER (ORDER BY month) AS change,
ROUND(100.0 * (revenue - LAG(revenue) OVER (ORDER BY month))
/ NULLIF(LAG(revenue) OVER (ORDER BY month), 0), 1) AS pct_change
FROM monthly_revenue;
LAG looks at the previous row, not the previous month. If months can be missing,
generate a complete calendar first (generate_series in Postgres, a date spine table
elsewhere) and left join, or the “previous” row may be two months back.
Running and moving aggregates
SUM(amount) OVER (PARTITION BY account_id ORDER BY posted_at, id
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS balance,
AVG(amount) OVER (ORDER BY day ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS avg_7_rows
A 7-row average is a 7-day average only if every day has exactly one row. For true
time windows use a date spine, or RANGE BETWEEN INTERVAL '6 days' PRECEDING AND CURRENT ROW
where supported (Postgres 11+, DuckDB, Snowflake has limits; MySQL supports
RANGE INTERVAL 6 DAY PRECEDING).
Gaps and islands (streaks, contiguous ranges)
WITH d AS (
SELECT user_id, activity_date,
activity_date - CAST(ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY activity_date) AS int) AS grp
FROM (SELECT DISTINCT user_id, activity_date FROM activity) x
)
SELECT user_id, MIN(activity_date) AS streak_start, MAX(activity_date) AS streak_end,
COUNT(*) AS days
FROM d GROUP BY user_id, grp;
Consecutive dates minus a consecutive row number give a constant per island. Dedupe
first, or duplicates break the arithmetic. Date minus integer is Postgres syntax; use
DATE_SUB/DATEADD/DATE_ADD in other dialects.
Sessionization (new session after 30 minutes idle)
WITH e AS (
SELECT user_id, ts,
CASE WHEN ts - LAG(ts) OVER (PARTITION BY user_id ORDER BY ts) > INTERVAL '30 minutes'
OR LAG(ts) OVER (PARTITION BY user_id ORDER BY ts) IS NULL
THEN 1 ELSE 0 END AS is_new
FROM events
)
SELECT user_id, ts,
SUM(is_new) OVER (PARTITION BY user_id ORDER BY ts ROWS UNBOUNDED PRECEDING) AS session_no
FROM e;
Share of total and percentiles
amount / SUM(amount) OVER (PARTITION BY region) AS share_of_region,
PERCENT_RANK() OVER (ORDER BY amount) AS pct_rank,
NTILE(4) OVER (ORDER BY amount) AS quartile
Watch integer division: cast one side to numeric/decimal in dialects that truncate (Postgres, SQL Server, SQLite).
Recursive CTEs (hierarchies, graphs)
WITH RECURSIVE tree AS (
SELECT id, parent_id, name, 1 AS depth, ARRAY[id] AS path
FROM categories WHERE parent_id IS NULL
UNION ALL
SELECT c.id, c.parent_id, c.name, t.depth + 1, t.path || c.id
FROM categories c JOIN tree t ON c.parent_id = t.id
WHERE NOT c.id = ANY(t.path) AND t.depth < 50 -- cycle and depth guard
)
SELECT * FROM tree;
Array paths are Postgres; elsewhere carry a delimited string or use the dialect’s
CYCLE clause (Postgres 14+ also has CYCLE id SET is_cycle USING path). SQL Server
omits the RECURSIVE keyword and caps recursion with OPTION (MAXRECURSION n).
Performance
- Each distinct
PARTITION BY/ORDER BYcombination needs its own sort. Reuse one window definition (WINDOW w AS (...), thenOVER w) and keep the number of distinct windows small. - An index on
(partition_cols, order_cols)lets Postgres and MySQL skip the sort. - Filter rows in WHERE before windowing whenever the filter does not change the window’s meaning. Filtering after (in an outer query) is required when it does, for example “rank among all, then show top 10 in region X”.
- In warehouses (BigQuery, Snowflake), a window over a huge unpartitioned set funnels through one worker; partition by something with many values.
Gotchas
- Non-deterministic order:
ROW_NUMBER() OVER (ORDER BY created_at)with duplicate timestamps picks arbitrarily and can change between runs. Add a unique tiebreaker. COUNT(*) OVER ()gives the total row count after WHERE, useful for pagination totals, but costs a full pass.- NULLs sort first or last depending on dialect (Postgres: last for ASC). State
NULLS LASTwhen it matters and the dialect supports it. LAG(x, 1, 0)default only applies when there is no previous row, not when the previous value is NULL.- Mixing window functions and GROUP BY: the window runs on grouped rows, so
SUM(SUM(amount)) OVER (...)is valid and often what you want.
Verification
- Grain stated; output row count matches it
- Every ORDER BY in OVER has a unique tiebreaker
- Frames explicit for aggregates and FIRST/LAST_VALUE
- Fixture with ties, NULLs, gaps and boundaries produces the hand-computed answer
- On real data, totals reconcile with a plain GROUP BY query
- Plan checked for repeated sorts on large inputs
Output template
## Query: <question answered>
**Dialect:** <engine + version>
**Grain:** one row per <...>
**SQL:** <query>
**Window choices:** <function, partition, order + tiebreaker, frame, and why>
**Checked with:** <fixture cases, reconciliation query> What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "SQL Window Functions"
short_description: "Rankings and running totals that are actually right"
default_prompt: "$sql-window-functions Get the latest status row per order from this history table" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.