Behnam Analytics

Work Analytics engineering

Outpatient analytics pipeline

A dbt-style SQL pipeline on SQLite that turns a messy outpatient extract into tested marts, with data tests that catch the planted problems and every rate defined once in YAML.

Synthetic data Every record here is generated. No real patient or organisational data is used.

DNA rate under the agreed definition; two other reports said 8.2% and 9.7%
9.4%
data tests that failed on the naive first version
8 of 28
appointments reconciled from 156,369 extract rows, every exclusion counted
148,603
ratio metrics defined once in YAML and compiled into the marts
5

An outpatient department asks one question, “what is our DNA rate?”, and gets three answers. This project builds the pipeline that makes the answer boring: staged, tested SQL models over a messy extract, and one metric definition that every mart compiles from.

It is plain SQL on SQLite with a small Python runner in the style of dbt, so anyone can run it with one command and no warehouse. The data is synthetic: two years of outpatient appointments shaped like a hospital patient administration system (PAS) feed, with problems planted on purpose so the tests have something real to find.

uv run python projects/outpatient-analytics-pipeline/run.py

That generates the extracts, builds a naive first version and the fixed pipeline, runs the same 28 data tests against both, and writes every chart and number on this page. It takes about 15 seconds.

The problem: three reports, three DNA rates

Three reports, all built from the same appointments, all labelled “DNA rate”:

Report Numerator Denominator Data DNA rate
A: board pack DNAs every booked appointment, cancellations included cleaned 8.2%
B: specialty dashboard DNAs attended plus DNA raw extract as received 9.7%
C: agreed definition DNAs attended plus DNA cleaned 9.4%

None of them is wrong arithmetic. The gap between A and C is a definition: whether a cancelled appointment belongs in the denominator. The gap between B and C is data handling: B counts re-sent rows twice, misses numeric legacy codes, and never sees the outcomes corrected after the extract. The three lines move together, and in none of the 24 months do they agree.

Three DNA rates from the same appointmentsMonthly, September 2024 to August 2026
Data table
MonthA: DNAs over all bookedB: raw extract as receivedC: agreed definition
2024-09-0110.2%12.3%11.5%
2024-10-018.2%9.8%9.3%
2024-11-019.2%11.4%10.5%
2024-12-0110.1%11.9%12.1%
2025-01-019.9%11.5%11.2%
2025-02-019.4%11.0%10.7%
2025-03-019.7%11.2%10.9%
2025-04-019.0%10.4%10.3%
2025-05-019.4%11.2%10.7%
2025-06-017.2%8.6%8.3%
2025-07-017.7%9.2%8.8%
2025-08-017.9%9.6%9.3%
2025-09-017.1%8.5%8.0%
2025-10-017.8%9.3%8.7%
2025-11-017.2%8.7%8.3%
2025-12-018.2%10.2%9.9%
2026-01-017.9%9.3%8.9%
2026-02-017.4%9.0%8.3%
2026-03-017.5%8.9%8.6%
2026-04-017.5%8.9%8.4%
2026-05-017.1%8.5%8.0%
2026-06-017.3%8.7%8.2%
2026-07-017.3%8.8%8.3%
2026-08-017.7%9.7%9.4%

Synthetic data. Source: projects/outpatient-analytics-pipeline.

Arguing about which report is right misses the point. The fix is a pipeline where the data problems are caught by tests and the definition lives in one place.

The source mess

The generator writes five files: a monthly appointment extract, a feed of outcome changes keyed after the extract, a clinic lookup, a specialty lookup and a list of planned clinic sessions with slot counts. The extract has 156,369 rows covering 148,763 appointments between September 2024 and August 2026, across ten specialties and 40 clinic codes.

What is planted in it:

Problem Rows
Repeat rows from two re-sent monthly extracts, sometimes with a newer outcome 7,606
Numeric legacy outcome codes (2 to 7) from one site until May 2025 14,217
Outcome blank because it was keyed after the extract 3,132
Outcome changes arriving later in the status feed 3,504
Blank specialty codes 3,839
Clinic codes in lower case or with trailing spaces 1,765
Appointments at three clinics missing from the clinic lookup 4,099
Training patients booked on the live system 160

On top of that, outcome codes come from three screens (Did Not Attend, dna, 3), and first and follow-up flags come in three schemes (N/F, New/FU/F/U in any case, 1/2). The README in the project code lists every planted pattern.

Pipeline design and lineage

The layers follow the usual convention. Staging models have one job each: make one source trustworthy, with types, codes and duplicates handled. Their only joins are to the code-mapping seeds. Intermediate models combine sources. Marts hold two fact tables at a stated grain and the reporting tables built on them.

Each model is one SELECT in a .sql file. A model declares its inputs with placeholders, {{ source('appointments') }} and {{ ref('stg_appointments') }}, and the runner reads those to build the dependency graph, then materialises each model as a table in topological order using Python’s graphlib. A cycle stops the build with the path:

pipeline error: dependency cycle: fct_appointments -> int_appointments_enriched -> fct_appointments

The lineage of the fixed pipeline:

raw_appointments ─────► stg_appointments ───┬──► int_appointment_status ──┐
raw_status_updates ───► stg_status_updates ─┘                             ├──► int_appointments_enriched
raw_clinics ──────────► stg_clinics ────────┬──► int_clinics ─────────────┘
                        stg_appointments ───┘

int_appointments_enriched ──┬──► fct_appointments ─────┬──► mart_dna_by_specialty_weekday
stg_specialties ────────────┘                          ├──► mart_dna_by_month
                                                       ├──► mart_new_to_follow_up_by_specialty
                                                       ├──► mart_lead_time_distribution
                                                       └──► mart_lead_time_by_specialty

raw_clinic_sessions ──► stg_clinic_sessions ──┐
fct_appointments ─────────────────────────────┤
int_clinics ──────────────────────────────────┼──► fct_clinic_sessions ──┬──► mart_utilisation_by_clinic
stg_specialties ──────────────────────────────┘                          └──► mart_utilisation_by_specialty

Seeds: attendance_status_map feeds both staging models of outcomes; visit_type_map feeds stg_appointments.

Two models carry most of the fixes. Staging keeps the latest copy of each appointment and maps codes through seed files rather than CASE statements, so a new code is a one-line change a service manager can review:

WITH ranked AS (
    SELECT
        *,
        ROW_NUMBER() OVER (
            PARTITION BY appointment_id
            ORDER BY extracted_at DESC
        ) AS copy_rank
    FROM {{ source('appointments') }}
)

SELECT
    r.appointment_id,
    UPPER(TRIM(r.clinic_code)) AS clinic_code,
    NULLIF(TRIM(r.specialty_code), '') AS specialty_code,
    CASE
        WHEN TRIM(r.attendance_status) = '' THEN 'NOT_RECORDED'
        ELSE COALESCE(s.attendance_outcome, 'UNMAPPED: ' || r.attendance_status)
    END AS attendance_outcome,
    r.extracted_at AS status_as_at
    -- other columns omitted
FROM ranked AS r
LEFT JOIN {{ ref('attendance_status_map') }} AS s
    ON s.raw_code = UPPER(TRIM(r.attendance_status))
WHERE r.copy_rank = 1
  AND r.patient_id NOT LIKE 'ZZ%'  -- training patients

An unknown code becomes UNMAPPED: <code> rather than NULL, so the accepted-values test names it instead of the value quietly vanishing. The intermediate model int_appointment_status then stacks the extract row and every later update and keeps whichever came last, so a DNA corrected to attended a fortnight later counts as attended. SQL window functions for patient pathways works through this keep-the-latest pattern with ROW_NUMBER, and the ways it goes wrong.

Tests and what they caught

The tests live in tests.yml, laid out like a dbt properties file: not_null, unique, accepted_values and relationships on columns, an expression_is_true check that bookings fit in the slots available, a freshness check on the extract, two singular SQL tests, and three reconciliations. Severity is error unless marked warn.

- name: fct_appointments
  columns:
    - name: clinic_code
      data_tests:
        - relationships:
            arguments: {to: ref('stg_clinics'), field: clinic_code}
            config: {severity: warn}  # the lookup owner's to-do list, not a pipeline fault

I wrote the naive version first, the way a first pass usually goes: read the extract as if it were clean, map the codes the main screen uses, inner join to the clinic lookup. Then I ran the same 28 tests against both builds.

Build Pass Warn Fail
Naive first version 18 2 8
Fixed pipeline 25 3 0

The tests that reported on either build (the other 16 pass on both):

Test Target Severity Naive Fixed
unique raw_appointments.appointment_id warn WARN 15,212 WARN 15,212
unique stg_appointments.appointment_id error FAIL 15,212 pass
not_null stg_appointments.visit_type error FAIL 58,642 pass
accepted_values stg_appointments.attendance_outcome error FAIL 24,393 pass
unique fct_appointments.appointment_id error FAIL 14,772 pass
relationships fct_appointments.specialty_code error FAIL 3,852 pass
relationships fct_appointments.clinic_code warn pass WARN 4,094
outcome_recorded fct_appointments warn pass WARN 201
bookings_fit_in_slots fct_clinic_sessions error FAIL 424 pass
assert_latest_status_applied singular test error FAIL 3,383 pass
assert_monthly_volume_stable singular test warn WARN 4 pass
reconciliation extract rows to fct_appointments error FAIL 1,739 pass

Numbers are failing rows (months, for the volume check), except the reconciliation, where it is the size of the gap. What each failure meant, and the fix:

  • Re-sent extracts broke uniqueness in staging (7,606 appointment IDs on 15,212 rows), pushed 424 clinic sessions over their slot count, and tripped the monthly volume check for March 2025, the month re-sent in full, and for the three months after it, whose baseline March had inflated. Fix in staging: keep the latest copy by extracted_at. The source-level unique test stays at warn on both builds, because re-sends are normal and staging is where they are resolved.
  • Outcome codes from three screens left 11 unexpected values on 24,393 rows, led by the legacy code 5 on 11,137 rows. Fix in staging: map through a seed.
  • Visit flags were NULL on 58,642 rows. accepted_values passed, because like dbt’s version it ignores NULLs; not_null caught it. Fix in staging: a second seed.
  • Blank specialty codes passed not_null, because an empty string is not NULL, and failed relationships on 3,852 rows. Fix: NULLIF(TRIM(...), '') in staging, then fill from the clinic lookup.
  • Late and corrected outcomes. Blanks showed up among the unexpected values above, but outcomes later corrected were invisible to every column test: a stale DNA is a valid code. The singular test that compares the fact table with the status feed found 3,383 appointments behind their latest update. Fix in intermediate: latest event wins.
  • The inner join to the clinic lookup dropped 6,027 rows, and the relationships test on clinic code passed, because the orphans were already gone. Only the reconciliation noticed, and even then the signal was muted. The build should have dropped 7,766 rows (re-sent copies and training patients). The join dropped 6,027, of which only 227 were among them; the other 5,800 were real appointments at clinics with badly typed or missing codes. Extra rows and lost rows largely cancelled, leaving a net gap of 1,739, about 1%.

That last one is the argument for reconciliations. The fixed build accounts for every row:

Step Rows
Rows in the raw extract 156,369
Less repeat rows from re-sent extracts -7,606
Less training patients -160
Expected in fct_appointments 148,603
Found in fct_appointments 148,603

The fixed build still reports three warnings, and they are correct. The source still contains re-sent rows, which staging resolves. Three clinics (ORTH05, DERM05, RESP04) are missing from the lookup, so the pipeline keeps their 4,094 appointments, takes each clinic’s specialty from its own appointment rows, and flags them for the lookup owner. And 201 appointments still have no outcome recorded, which is a job for the clinics rather than the pipeline.

Metric definitions

Every rate is defined once in metrics.yml: the table it is counted on, the grain, the date it is attributed to, and a numerator and denominator, each an aggregation with a filter. Exclusions are written down in words next to the filters that implement them.

dna_rate:
  label: DNA rate
  model: fct_appointments
  grain: one appointment
  time: appointment_date
  numerator:
    agg: count
    where: attendance_outcome = 'DNA'
  denominator:
    agg: count
    where: attendance_outcome IN ('ATTENDED', 'DNA')
  exclusions:
    - Cancellations by the patient or the hospital are in neither part.
    - Arrived late and could not be seen (legacy code 7) counts as a DNA.

A mart asks for the metric by name and chooses only the grouping:

SELECT
    specialty_name,
    weekday_name,
    visit_type,
    {{ metric('dna_rate') }}
FROM {{ ref('fct_appointments') }}
GROUP BY specialty_name, weekday_name, visit_type

The runner compiles the placeholder into three columns, the numerator, the denominator and the ratio:

SUM(CASE WHEN attendance_outcome = 'DNA' THEN 1 ELSE 0 END) AS dna_rate_numerator,
SUM(CASE WHEN attendance_outcome IN ('ATTENDED', 'DNA') THEN 1 ELSE 0 END) AS dna_rate_denominator,
1.0 * SUM(CASE WHEN attendance_outcome = 'DNA' THEN 1 ELSE 0 END)
    / NULLIF(SUM(CASE WHEN attendance_outcome IN ('ATTENDED', 'DNA') THEN 1 ELSE 0 END), 0) AS dna_rate

Keeping the parts is what makes roll-ups safe: a specialty total is the sum of numerators over the sum of denominators, never an average of weekday rates. The compiler also refuses a metric on the wrong table. Asking for dna_rate in a model that selects from fct_clinic_sessions fails with metric 'dna_rate' is defined on fct_appointments, which this model does not select from.

The five metrics are DNA rate, hospital and patient cancellation rates, slot utilisation (attended appointments over slots in sessions that went ahead) and the new-to-follow-up ratio (follow-up attendances per first attendance).

Results

Over the two years, 12,188 of 129,670 appointments that went ahead as booked were DNAs: 9.4%. First appointments ran higher than follow-ups, 10.8% against 8.6%. Mondays (10.2%) and Fridays (10.7%) were worse than midweek (8.7% to 8.9%).

DNA rate by weekdayAgreed definition, all specialties, September 2024 to August 2026
Data table
WeekdayAll appointmentsFirst appointmentsFollow-ups
Monday10.2%11.6%9.4%
Tuesday8.7%10.0%7.9%
Wednesday8.9%10.6%7.9%
Thursday8.8%10.0%8.1%
Friday10.7%12.3%9.7%

Synthetic data. Source: projects/outpatient-analytics-pipeline.

By month, the rate peaks in December and falls from 10.8% before June 2025 to 8.6% after it. That step is planted (the generator cuts DNA odds by about a fifth when text reminders start), so treat it as a check that the marts can show a real change, not as evidence about reminders. That evidence needs a randomised trial, like the appointment reminder experiment.

DNA rate by monthAgreed definition; the June 2025 step is planted in the data
Data table
MonthAll appointmentsFirst appointmentsFollow-ups
2024-09-0111.5%12.9%10.8%
2024-10-019.3%10.7%8.6%
2024-11-0110.5%12.0%9.6%
2024-12-0112.1%14.4%10.7%
2025-01-0111.2%12.4%10.5%
2025-02-0110.7%12.0%10.0%
2025-03-0110.9%11.9%10.2%
2025-04-0110.3%11.7%9.5%
2025-05-0110.7%11.5%10.2%
2025-06-018.3%9.5%7.5%
2025-07-018.8%10.8%7.7%
2025-08-019.3%10.9%8.4%
2025-09-018.0%9.1%7.4%
2025-10-018.7%10.4%7.8%
2025-11-018.3%9.8%7.4%
2025-12-019.9%10.8%9.4%
2026-01-018.9%10.2%8.1%
2026-02-018.3%10.5%6.9%
2026-03-018.6%10.1%7.7%
2026-04-018.4%9.9%7.6%
2026-05-018.0%10.0%6.8%
2026-06-018.2%9.1%7.7%
2026-07-018.3%9.5%7.6%
2026-08-019.4%11.0%8.5%

Synthetic data. Source: projects/outpatient-analytics-pipeline.

Slot utilisation was 82.3% across 40 clinics: of the slots in sessions that went ahead, that share went to a patient who was seen. The hospital cancelled 418 of 10,667 planned sessions. Hospital cancellations were 5.0% of booked appointments and patient cancellations 7.6%.

Slot utilisation by specialtyAttended appointments as a share of slots in sessions that went ahead
Data table
SpecialtySlot utilisation
Ophthalmology87.8%
Dermatology86.4%
Trauma and orthopaedics85.4%
Cardiology84.2%
Urology82.3%
Ear, nose and throat82.0%
Gynaecology80.6%
General surgery80.1%
Gastroenterology77.8%
Respiratory medicine77.6%

Synthetic data. Source: projects/outpatient-analytics-pipeline.

The median wait from referral to an attended first appointment was 11.7 weeks, with a long right tail: one in ten waited more than 23.9 weeks.

Referral to first appointmentShare of attended first appointments by wait, in two-week bands
Data table
Weeks from referralAll specialties
0-20.0%
2-42.3%
4-68.4%
6-813.4%
8-1014.2%
10-1212.9%
12-1411.1%
14-168.8%
16-186.8%
18-205.1%
20-224.0%
22-243.0%
24-262.3%
26-281.8%
28-301.3%
30+4.4%

Synthetic data. Source: projects/outpatient-analytics-pipeline.

Specialty DNA rate Slot utilisation New : follow-up Median wait (weeks) 90th percentile (weeks)
Cardiology 7.8% 84.2% 1 : 2.42 10.7 20.9
Dermatology 7.6% 86.4% 1 : 1.41 14.7 28.0
Ear, nose and throat 9.2% 82.0% 1 : 1.51 12.7 24.0
Gastroenterology 12.2% 77.8% 1 : 2.09 12.0 22.3
General surgery 10.4% 80.1% 1 : 1.23 8.9 17.0
Gynaecology 11.0% 80.6% 1 : 1.30 8.0 15.1
Ophthalmology 7.1% 87.8% 1 : 3.71 17.0 31.9
Respiratory medicine 11.5% 77.6% 1 : 1.90 9.7 18.4
Trauma and orthopaedics 7.9% 85.4% 1 : 1.83 15.7 29.9
Urology 9.0% 82.3% 1 : 1.67 10.9 20.1

The two specialties with the highest DNA rates, gastroenterology and respiratory medicine, also have the lowest utilisation. That follows from the definition: a DNA is a booked slot nobody used.

Limits

  • The data is synthetic and the patterns are planted. The weekday, seasonal, specialty and reminder effects are there because the generator put them there. The pipeline shows them faithfully; it does not discover them.
  • Tests check shape, not truth. No column test caught the stale outcomes. The singular test exists because I knew the status feed existed. A pipeline test suite is only as good as the analyst’s knowledge of the source.
  • The runner is a teaching tool. It rebuilds everything each run, has no incremental models, snapshots, parallelism or documentation site, and SQLite has none of a warehouse’s access control.
  • Some choices are local rules, written down. Code 7 (arrived late, not seen) counts as a DNA; utilisation counts attended slots, not booked ones; a clinic missing from the lookup takes the specialty most often recorded on its own appointments until someone fixes the lookup.
  • Lead time is referral to first attended appointment. It is not a referral-to-treatment wait and should not be compared with one.
  • Freshness is checked against a fixed date (14 September 2026) so the run is reproducible. In production it would be checked against the time of the run.

What next

  • Move the same SQL to dbt on a warehouse. The models already use ref() and source(), though dbt’s source() takes a source name and a table name. Seeds move to seeds/ and the singular tests to tests/. Most column tests carry over as written; freshness becomes a source setting, and expression_is_true comes from the dbt_utils package. metrics.yml would be rewritten for whichever semantic layer the reporting tool reads.
  • Load the extract incrementally and snapshot the clinic lookup, so a clinic that changes specialty keeps its history.
  • Store test failures as tables and send the warnings to the people who can act on them: unmapped clinics to the booking team, unrecorded outcomes to the clinics.
  • Add distribution tests: monthly volumes and DNA rates checked against their own recent history, so a partial extract or a new code that maps to the wrong outcome shows up as a shift before anyone reads the dashboard.

Built with

  • SQL
  • SQLite
  • Python
  • pandas
  • PyYAML

Downloads