Behnam Analytics

Writing Analytics engineering

SQL window functions for patient pathways

ROW_NUMBER, RANK, LAG and window frames applied to first attendances, latest statuses, seven-day totals, continuous spells and 30-day readmissions, with real output and the mistakes that change the answer.

Behnam Ebrahimi 13 min read

Most pathway questions compare a row with its neighbours: which appointment came first, what the latest status is, how long since the last discharge, whether this stay continues the one before it. A window function answers that kind of question in one pass, without a self-join. It also lets you get the answer quietly wrong. A tie, a default frame or a filter in the wrong place can each change a count without raising an error.

This article works through five pathway patterns: ranking, first attendance, latest status, running totals with frames, and continuous spells leading to 30-day readmissions. Every query ran in SQLite 3.50 against small synthetic tables, and every output table below is what it returned. The tables are written out in sql_window_functions.py, so you can see every edge case I planted.

Ranking: ROW_NUMBER, RANK and DENSE_RANK

All three number the rows in each partition in ORDER BY order. They differ only on ties. Pathway R2 has two attended appointments on the same day, 12 February:

SELECT pathway_id, appt_id, appt_date, outcome,
       ROW_NUMBER() OVER (PARTITION BY pathway_id ORDER BY appt_date) AS row_num,
       RANK()       OVER (PARTITION BY pathway_id ORDER BY appt_date) AS rank_,
       DENSE_RANK() OVER (PARTITION BY pathway_id ORDER BY appt_date) AS dense_rank_
FROM appointments
WHERE pathway_id = 'R2'
ORDER BY appt_date, appt_id;
pathway_id appt_id appt_date outcome row_num rank_ dense_rank_
R2 4 2025-02-05 Cancelled by hospital 1 1 1
R2 5 2025-02-12 Attended 2 2 2
R2 6 2025-02-12 Attended 3 2 2
R2 7 2025-03-05 Attended 4 4 3

ROW_NUMBER always gives distinct numbers, so it has to break the tie somehow. Here it gave appointment 5 the 2, but nothing in the query says it must: the ORDER BY puts the two rows level, so which one comes first is up to the engine. Another engine, or the same one with a different plan, can swap them. RANK gives both rows 2 and skips 3. DENSE_RANK gives both 2 and doesn’t skip.

The rule I follow: whenever I use ROW_NUMBER to pick one row, the ORDER BY ends in a column that is unique within the partition. If it doesn’t, the pick isn’t reproducible.

First attendance per pathway

“Days from referral to first attendance” needs the first appointment the patient actually attended. Filter to attendances first, then number them:

WITH attended AS (
    SELECT pathway_id, appt_id, appt_date,
           ROW_NUMBER() OVER (
               PARTITION BY pathway_id
               ORDER BY appt_date, appt_id
           ) AS rn
    FROM appointments
    WHERE outcome = 'Attended'
)
SELECT p.pathway_id, p.referral_date,
       a.appt_id AS first_appt_id,
       a.appt_date AS first_attended,
       julianday(a.appt_date) - julianday(p.referral_date) AS days_to_first
FROM pathways AS p
LEFT JOIN attended AS a
       ON a.pathway_id = p.pathway_id AND a.rn = 1
ORDER BY p.pathway_id;
pathway_id referral_date first_appt_id first_attended days_to_first
R1 2025-01-06 2 2025-02-17 42
R2 2025-01-13 5 2025-02-12 30
R3 2025-01-20 NULL NULL NULL
R4 2025-01-27 10 2025-02-07 11

The LEFT JOIN from the pathways table keeps R3, which has had two DNAs and no attendance yet. Dropping it would flatter the average wait.

The common mistake is to number every appointment and filter afterwards, with WHERE rn = 1 AND outcome = 'Attended'. That returns one row, R4. R1’s first appointment was a DNA and R2’s was cancelled by the hospital, so their row 1 isn’t an attendance and both pathways vanish. The window function runs after WHERE, so a filter inside the CTE shapes what gets numbered, and a filter outside it only throws rows away.

Latest status per referral

Status histories arrive with duplicates and ties. In this one, R1’s “Appointment booked” event was loaded twice, and R2 has two events stamped 16:45 on the same day. Keep one row per referral with ROW_NUMBER and a full tie-break:

WITH ranked AS (
    SELECT referral_id, event_id, status, status_time, loaded_at,
           ROW_NUMBER() OVER (
               PARTITION BY referral_id
               ORDER BY status_time DESC, event_id DESC, loaded_at DESC
           ) AS rn
    FROM referral_status
)
SELECT referral_id, event_id, status, status_time
FROM ranked
WHERE rn = 1
ORDER BY referral_id;
referral_id event_id status status_time
R1 1002 Appointment booked 2025-01-27 14:10
R2 2003 Discharged 2025-02-14 16:45
R3 3002 Treatment started 2025-03-03 08:30

The tie-break is a business rule, not decoration. I’ve assumed the source’s event ID increases with each event, so on a tie the higher ID is the later status. Check that with whoever owns the source system before relying on it. loaded_at comes last, so the two identical copies of event 1002 still resolve to one row.

With RANK() ... ORDER BY status_time DESC and WHERE rnk = 1, the same data returns five rows: both copies of R1’s event, and both of R2’s tied statuses. Every downstream count of referrals by status is now inflated, and R2 is both booked and discharged. RANK is the right choice only when you want the ties.

Running totals and the default frame

An aggregate with OVER (ORDER BY ...) gives a running total. What it sums depends on the frame, and if you don’t write one you get the default. SQLite, PostgreSQL and SQL Server all document the same default when ORDER BY is present: RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. In RANGE mode, “current row” includes every row that ties with it on the ORDER BY value.

SELECT appt_id, appt_date,
       COUNT(*) OVER (ORDER BY appt_date) AS default_frame,
       COUNT(*) OVER (
           ORDER BY appt_date
           ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
       ) AS rows_frame
FROM appointments
WHERE pathway_id = 'R2'
ORDER BY appt_date, appt_id;
appt_id appt_date default_frame rows_frame
4 2025-02-05 1 1
5 2025-02-12 3 2
6 2025-02-12 3 3
7 2025-03-05 4 4

With the default frame, both appointments on 12 February see each other, so both show 3. Neither answer is wrong, but they answer different questions, and the one you get by accident is the one fewer people expect. Write the frame out every time.

Seven rows are not seven days

The frame matters most for rolling totals. The clinic_days table has one row per clinic day and no rows for weekends or bank holidays, which is how most activity tables look. Here are two “last seven days” totals:

SELECT clinic_date, attendances,
       SUM(attendances) OVER (
           ORDER BY clinic_date
           ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
       ) AS last_7_rows,
       SUM(attendances) OVER (
           ORDER BY julianday(clinic_date)
           RANGE BETWEEN 6 PRECEDING AND CURRENT ROW
       ) AS last_7_days
FROM clinic_days
ORDER BY clinic_date;

For the week after Easter:

clinic_date attendances last_7_rows last_7_days
2026-04-07 58 327 166
2026-04-08 38 329 151
2026-04-09 51 338 147
2026-04-10 34 327 181
2026-04-13 58 347 239
2026-04-14 53 347 234

ROWS BETWEEN 6 PRECEDING means the last seven rows. On a table with no weekend rows, that’s seven clinic days, which span nine calendar days in a normal week and more around bank holidays. RANGE BETWEEN 6 PRECEDING on a day number means the last seven calendar days, whatever rows exist. Across the 12 weeks, once both windows were full, the ROWS total averaged 1.48 times the true seven-day total, and 2.30 times at its worst, after Easter.

Seven-day attendance totals from two window framesOne clinic, weekdays only; ROWS counts the last 7 rows, RANGE the last 7 days
Data table
Clinic dateROWS: last 7 clinic daysRANGE: last 7 calendar days
2026-03-10283194
2026-03-11276190
2026-03-12274187
2026-03-13254180
2026-03-16266192
2026-03-17277203
2026-03-18274199
2026-03-19283203
2026-03-20274210
2026-03-23281202
2026-03-24295198
2026-03-25280198
2026-03-26272192
2026-03-27276201
2026-03-30279201
2026-03-31284199
2026-04-01292214
2026-04-02307233
2026-04-07327166
2026-04-08329151
2026-04-09338147
2026-04-10327181
2026-04-13347239
2026-04-14347234
2026-04-15335239
2026-04-16330241
2026-04-17327242
2026-04-20331239
2026-04-21329218
2026-04-22322226
2026-04-23313217
2026-04-24301213
2026-04-27292202
2026-04-28296209
2026-04-29280197
2026-04-30285190
2026-05-01257182
2026-05-05261147
2026-05-06282160
2026-05-07280165
2026-05-08276177
2026-05-11283223
2026-05-12285214
2026-05-13298198
2026-05-14296202
2026-05-15289212
2026-05-18296215
2026-05-19301216
2026-05-20303228
2026-05-21302220
2026-05-22294203

Synthetic data. Source: projects/article-examples/round-two/sql_window_functions.py.

Nothing about the ROWS line looks wrong on its own; it’s only wrong against its label. If a dashboard calls it “last 7 days”, it overstates weekly activity by about half.

RANGE with a numeric offset is not portable. SQLite has supported it since 3.28.0, and PostgreSQL accepts it with an interval offset on a date column (RANGE BETWEEN '6 days' PRECEDING AND CURRENT ROW). SQL Server does not: its documentation says RANGE can’t be used with a numeric PRECEDING or FOLLOWING value. The portable fix is a calendar spine, one row per day with zeros filled in, after which ROWS counts days:

WITH RECURSIVE calendar(d) AS (
    SELECT '2026-03-02'
    UNION ALL
    SELECT date(d, '+1 day') FROM calendar WHERE d < '2026-05-22'
),
filled AS (
    SELECT c.d AS clinic_date, COALESCE(a.attendances, 0) AS attendances
    FROM calendar AS c
    LEFT JOIN clinic_days AS a ON a.clinic_date = c.d
)
SELECT clinic_date,
       SUM(attendances) OVER (
           ORDER BY clinic_date
           ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
       ) AS last_7_days
FROM filled;

The script checks that this matches the RANGE version on every clinic day, and it does. In a warehouse you’d join a date dimension rather than generate the calendar each time.

Continuous spells: gaps and islands

A patient transferred between hospitals, or with overlapping records, has several stays that are really one continuous period in hospital. Finding those periods is a gaps-and-islands problem: flag each stay that starts a new island, then turn the flags into island numbers with a running sum.

The flag needs care. A stay starts a new spell if it begins after the latest discharge of any earlier stay, not just the previous one. That is a running MAX, with a frame that stops one row before the current row:

WITH ordered AS (
    SELECT patient_id, stay_id, admit_date, discharge_date,
           MAX(discharge_date) OVER (
               PARTITION BY patient_id
               ORDER BY admit_date, stay_id
               ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING
           ) AS latest_discharge_before
    FROM stays
),
flagged AS (
    SELECT *,
           CASE WHEN latest_discharge_before IS NULL
                  OR admit_date > latest_discharge_before
                THEN 1 ELSE 0 END AS new_spell
    FROM ordered
)
SELECT patient_id, stay_id, admit_date, discharge_date,
       latest_discharge_before, new_spell,
       SUM(new_spell) OVER (
           PARTITION BY patient_id
           ORDER BY admit_date, stay_id
           ROWS UNBOUNDED PRECEDING
       ) AS spell_no
FROM flagged
WHERE patient_id IN ('P01', 'P03')
ORDER BY patient_id, admit_date, stay_id;
patient_id stay_id admit_date discharge_date latest_discharge_before new_spell spell_no
P01 101 2025-01-03 2025-01-08 NULL 1 1
P01 102 2025-01-08 2025-01-15 2025-01-08 0 1
P01 103 2025-02-01 2025-02-04 2025-01-15 1 2
P03 301 2025-01-05 2025-01-25 NULL 1 1
P03 302 2025-01-10 2025-01-12 2025-01-25 0 1
P03 303 2025-01-22 2025-01-30 2025-01-25 0 1
P03 304 2025-02-20 2025-02-23 2025-01-30 1 2

P01’s second stay starts on the day the first ended, a transfer, so it joins spell 1. P03’s stay 302 sits inside stay 301, and 303 overlaps its end, so all three form one spell from 5 to 30 January.

The version most people write first uses LAG(discharge_date). For P03 it compares stay 303’s admission (22 January) with stay 302’s discharge (12 January), sees a gap, and starts a new spell in the middle of a stay that ran until the 25th. LAG sees only the previous row; nested and overlapping stays need everything before it. Dates are ISO text in SQLite, so MAX and > compare them correctly. Use real date types elsewhere.

Readmissions within 30 days

With spells in place, LAG is the right tool: each spell’s gap is measured from the end of the previous spell. The query below builds the spells as above, takes each spell’s admission method from its first stay with FIRST_VALUE, and then measures the gaps:

WITH ordered AS (
    SELECT *,
           MAX(discharge_date) OVER (
               PARTITION BY patient_id ORDER BY admit_date, stay_id
               ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING
           ) AS latest_discharge_before
    FROM stays
),
numbered AS (
    SELECT *,
           SUM(CASE WHEN latest_discharge_before IS NULL
                      OR admit_date > latest_discharge_before
                    THEN 1 ELSE 0 END) OVER (
               PARTITION BY patient_id ORDER BY admit_date, stay_id
               ROWS UNBOUNDED PRECEDING
           ) AS spell_no
    FROM ordered
),
spells AS (
    SELECT patient_id, spell_no, method,
           MIN(admit_date) AS spell_start,
           MAX(discharge_date) AS spell_end
    FROM (
        SELECT *,
               FIRST_VALUE(admission_method) OVER (
                   PARTITION BY patient_id, spell_no ORDER BY admit_date, stay_id
               ) AS method
        FROM numbered
    )
    GROUP BY patient_id, spell_no, method
),
gaps AS (
    SELECT *,
           julianday(spell_start)
             - julianday(LAG(spell_end) OVER (
                   PARTITION BY patient_id ORDER BY spell_start)) AS days_since_discharge
    FROM spells
)
SELECT patient_id, spell_no, spell_start, spell_end, method, days_since_discharge,
       CASE WHEN method = 'Emergency' AND days_since_discharge <= 30
            THEN 1 ELSE 0 END AS readmit_30d
FROM gaps
ORDER BY patient_id, spell_no;
patient_id spell_no spell_start spell_end method days_since_discharge readmit_30d
P01 1 2025-01-03 2025-01-15 Emergency NULL 0
P01 2 2025-02-01 2025-02-04 Emergency 17 1
P02 1 2025-01-10 2025-01-20 Elective NULL 0
P02 2 2025-03-05 2025-03-07 Emergency 44 0
P03 1 2025-01-05 2025-01-30 Emergency NULL 0
P03 2 2025-02-20 2025-02-23 Emergency 21 1
P04 1 2025-02-01 2025-02-02 Emergency NULL 0
P04 2 2025-02-03 2025-02-10 Emergency 1 1
P05 1 2025-03-10 2025-03-14 Elective NULL 0
P05 2 2025-03-28 2025-03-29 Elective 14 0
P06 1 2025-03-01 2025-03-04 Emergency NULL 0
P06 2 2025-03-20 2025-03-25 Emergency 16 1
P06 3 2025-04-10 2025-04-12 Emergency 16 1

Five emergency readmissions from 13 spells. P05’s planned return inside 30 days doesn’t count, and P04’s next-day emergency admission does.

Run the same LAG directly on the 16 stays and it finds eight. The three extra are P01’s same-day transfer (0 days), P03’s overlapping stay (10 days) and P03’s nested stay at −15 days, which passes <= 30 because nobody expects a negative gap. Three of the eight “readmissions” happened inside a continuous spell in hospital. A negative interval in your output is always worth investigating.

Two more choices belong in the written definition. This query counts readmissions on the returning spell. A rate per index discharge flips it around: use LEAD(spell_start) to look forward from each discharge, and exclude index spells that ended in death. Published readmission indicators add their own exclusions, so match the one you’re reporting against rather than inventing yours. Metric definitions as code covers how to write those choices down once.

Dialect notes I checked

  • Default frame. With ORDER BY and no frame clause, SQLite, PostgreSQL and SQL Server all use RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. Ranking functions don’t take a frame, so this doesn’t affect them.
  • RANGE with an offset. SQLite 3.28.0 and later, and PostgreSQL (interval offsets for dates), support it. SQL Server supports RANGE only with UNBOUNDED and CURRENT ROW, so use a calendar table and ROWS.
  • Date differences. SQLite has no date type, so I used julianday(). In SQL Server use DATEDIFF(day, earlier, later); in PostgreSQL, subtracting one date from another gives whole days.
  • Filtering on a window function. In SQLite, PostgreSQL and SQL Server you can’t put a window function in WHERE, because it’s evaluated after WHERE. Wrap it in a CTE or subquery, as every example here does.

For where these patterns sit in a pipeline, the outpatient analytics pipeline uses the same ROW_NUMBER pattern to keep the latest copy of each appointment, and data tests that catch real problems covers the uniqueness test that proves it worked. The difference between stays, episodes and spells is covered in a star schema for hospital activity.

Reproduce

The tables, queries and chart come from one script. It builds an in-memory SQLite database, runs every query in this article and writes the output tables to projects/article-examples/round-two/output/sql-window-functions.md:

uv run python projects/article-examples/round-two/sql_window_functions.py