Behnam Analytics

Writing Power BI, DAX & TMDL

A star schema for hospital activity

How I'd design a Power BI star schema for admissions, outpatients and waiting lists, from grain decisions and conformed dimensions to role-playing dates, relationship direction and semi-additive snapshots.

Behnam Ebrahimi 10 min read

A slow or untrustworthy Power BI report more often has a model problem than a DAX problem. Hospital activity data makes this likely, because it arrives shaped by the systems that record it: patient administration extracts with one row per consultant episode, outpatient files with one row per appointment slot, and waiting-list files that are really a census of open pathways taken every week.

This is the design I’d start from, using a synthetic acute hospital with two sites, seven specialties and data from April 2023 to 20 August 2026. The generator is in projects/article-examples/power-bi/, and the tables below come from it.

The model

    +----------------------------------------------------------------------+
    | Date   (Date, Financial Year, Month, Week, Weekday)                  |
    +----------------------------------------------------------------------+
        |             :                    |                     |
        | admission   : discharge          | appointment         | snapshot
        | date        : date               | date                | date
        | (active)    : (inactive)         |                     |
        v             v                    v                     v
  +----------------------------+   +------------------+   +----------------------+
  | Admissions                 |   | Outpatients      |   | Waiting List         |
  | 1 row per spell            |   | 1 row per        |   | 1 row per snapshot,  |
  |                            |   | appointment      |   | specialty, site,     |
  |                            |   |                  |   | weeks waiting        |
  +----------------------------+   +------------------+   +----------------------+
               ^                            ^                       ^
               |                            |                       |
Specialty -----+----------------------------+-----------------------+
Site ----------+----------------------------+-----------------------+
Demographics --+----------------------------+

Three fact tables, four dimensions, every relationship one-to-many and filtering in one direction, from dimension to fact. Each design choice below is one I’d defend in a model review.

Grain first: episode, spell or pathway

The grain of a fact table is what one row means. Get it written down before you load anything, because the three candidate grains in hospital data answer different questions:

  • A consultant episode is the time a patient spends under one consultant. A patient who is transferred from a general surgeon to a cardiologist has two episodes.
  • A spell is one continuous stay in the hospital, from admission to discharge. It contains one or more episodes.
  • An RTT pathway runs from referral to the start of treatment. It can contain several outpatient appointments and, eventually, an admission.

“How many admissions did we have?” is a spell question. Counting episode rows answers something else. In the synthetic data, 10% of emergency spells and 3% of elective spells have more than one episode, and some later episodes move to another specialty. Three attempts at admissions by specialty for FY 2025/26:

-- Wrong: counts consultant episodes, not admissions
Admissions := COUNTROWS ( Episodes )

-- Better, but not additive across specialties
Admissions := DISTINCTCOUNT ( Episodes[Spell ID] )

-- Right: a spell-grain fact table
Admissions := COUNTROWS ( Admissions )
Specialty COUNTROWS ( Episodes ) DISTINCTCOUNT ( Episodes[Spell ID] ) COUNTROWS ( Spells )
General surgery 9,908 9,368 9,048
Trauma and orthopaedics 8,803 8,310 8,032
ENT 3,526 3,393 3,282
Ophthalmology 4,569 4,411 4,270
General medicine 21,741 20,004 19,570
Cardiology 7,685 7,217 6,984
Dermatology 2,979 2,887 2,794
Total 59,211 53,980 53,980

Counting episodes overstates FY 2025/26 admissions by 9.7%. The distinct count gets the total right, but a spell with episodes in two specialties appears in both rows, so the rows add up to 55,590 while the total says 53,980. That’s hard to explain to someone reconciling against a published return. It also puts a distinct count over a near-unique column into every visual, which gets expensive as the table grows; the VertiPaq article covers why cardinality drives cost.

The spell-grain table avoids both problems, but it forces a decision you need to document: which specialty does a spell belong to? Here it’s the specialty of the first episode, the admitting specialty. Discharging specialty is also defensible. What matters is that the choice is written into the table’s description in the model, so nobody has to guess. If you need consultant-level analysis, keep Episodes as a second fact table rather than making it the only one. If the source has no spell ID, SQL window functions for patient pathways shows how to build continuous spells from overlapping and back-to-back stays.

Event facts and periodic snapshots

Admissions and outpatient appointments are event facts: one row per thing that happened, stamped with the date it happened. They add up across every dimension, including time.

The waiting list is different. It’s a periodic snapshot: every Sunday, a count of the pathways that are open, by specialty, site and weeks waited. Snapshots add up across specialty and site, but not across time. The same patient is on the list every week until treatment, so summing four weekly snapshots counts them four times.

-- Wrong in any visual that shows more than one snapshot per cell
Waiting List := SUM ( 'Waiting List'[Pathways] )

-- Right: the last snapshot in the period
Waiting List :=
VAR LastSnapshot = MAX ( 'Waiting List'[Snapshot Date] )
RETURN
    CALCULATE ( SUM ( 'Waiting List'[Pathways] ), 'Date'[Date] = LastSnapshot )
Month Snapshots SUM over snapshots Last snapshot
May 2026 5 125,630 25,270
Jun 2026 4 101,369 25,441
Jul 2026 4 102,036 25,565

The summed version is four or five times too large and jumps whenever a month has five Sundays. The corrected measure takes the latest snapshot date visible in the current filter context, so a month shows its last week, a financial year shows its last week, and a single week shows itself. DAX for waiting-list KPIs goes further into RTT measures.

Pathways can also be modelled as an accumulating snapshot: one row per pathway, with a column for each milestone (referral, first appointment, decision to treat, clock stop) that fills in as the pathway progresses. That’s the right shape for questions like “how long from referral to first appointment?”, and it brings in more role-playing dates. For a complete waiting-list model, see the RTT waiting list semantic model project.

Conformed dimensions

A dimension is conformed when every fact table that uses it uses the same one, with the same keys and the same labels. That’s what lets one visual show three facts side by side:

Specialty Admissions Outpatient attendances Waiting list at 29 Mar 2026
General surgery 9,048 12,241 3,867
Trauma and orthopaedics 8,032 16,997 5,762
ENT 3,282 11,360 3,417
Ophthalmology 4,270 18,891 5,272
General medicine 19,570 6,577 876
Cardiology 6,984 11,392 2,456
Dermatology 2,794 17,067 3,200
Total 53,980 94,525 24,850

The dimensions I’d conform across hospital activity:

  • Date, one row per day, with financial year (April to March), month, ISO week and weekday. The time intelligence article covers what else it needs.
  • Specialty, one row per treatment function code, with the specialty name and division as columns. Division lives in this table, not in a separate one (more on snowflakes below).
  • Site, one row per hospital site.
  • Demographics, with age band, sex and deprivation quintile, and no identifiers. A reporting model whose users don’t need patient-level detail has no reason to hold an NHS number, date of birth or postcode. Derive the bands upstream and load only the bands.

Demographics works well as a junk dimension: every combination of five age bands, three sex codes (including “Not known”) and five deprivation quintiles, 75 rows in all, with one integer key on each fact row. Three low-cardinality attributes become one small table and one small key column, instead of three columns repeated on every fact row.

Role-playing dates

An admission has an admission date and a discharge date, and both need the Date dimension. Power BI allows only one active relationship between two tables, so one relationship is active (admission date) and the other is inactive, used by the measures that need it:

-- Wrong: counts discharged spells by the day they were *admitted*
Discharges :=
CALCULATE ( [Admissions], NOT ISBLANK ( Admissions[Discharge Date] ) )

-- Right: switch the relationship for this measure
Discharges :=
CALCULATE (
    [Admissions],
    USERELATIONSHIP ( Admissions[Discharge Date], 'Date'[Date] ),
    NOT ISBLANK ( Admissions[Discharge Date] )
)

With 'Date'[Weekday] on the rows for FY 2025/26:

Weekday Admissions Discharges (naive) Discharges (USERELATIONSHIP)
Monday 8,444 8,444 13,102
Tuesday 8,763 8,763 8,007
Wednesday 8,445 8,445 8,374
Thursday 8,339 8,339 8,246
Friday 8,154 8,154 8,233
Saturday 6,125 6,125 4,146
Sunday 5,710 5,710 3,890

The naive measure just repeats the admissions pattern, because the filter still arrives through the admission date. The corrected one shows what the synthetic data was built to contain: half of the overnight stays due to end at a weekend run on to Monday. That weekend discharge gap is exactly what a flow report exists to show, and the naive measure hides it completely. The NOT ISBLANK filter keeps patients who are still in hospital (457 spells at the extract date) out of the count when no date filter is applied.

The alternative is a second copy of the date table, Discharge Date, with its own active relationship. I’d use that when people need to filter by both roles at once (“admitted in March, discharged in April”). It costs a small extra table and some care with column names, so that “Year” in one table isn’t confused with “Year” in the other.

Relationship direction and cardinality

Every relationship in this model is one-to-many, from dimension to fact, filtering in a single direction. Three rules keep it that way:

  • One-to-many only. A one-to-one relationship usually means two tables should be one. A many-to-many between a dimension and a fact usually means the dimension has duplicate keys, which is a data quality problem to fix upstream.
  • Single direction by default. A bidirectional relationship lets filters flow from a fact back into a dimension and then on into the other facts that share it. A slicer meant for admissions starts quietly filtering the waiting list. Bidirectional filtering also costs query time, and Microsoft’s guidance is to minimise it. When a slicer should only list items with data, filter the slicer visual with a measure ([Admissions] is not blank). When one calculation needs the other direction, turn it on inside that measure only with CROSSFILTER ( Admissions[Specialty Key], 'Specialty'[Specialty Key], BOTH ).
  • Integer or date keys. Relate dates on a column of Date type, so time intelligence behaves (the time intelligence article explains why), and use integer surrogate keys for the other dimensions.

Why not a snowflake

Source systems often hold specialty and division as separate tables, and it’s tempting to load them that way: Division related to Specialty, Specialty related to the facts. That snowflake has costs. Filters travel along a longer chain of relationships, the Data pane shows extra tables that hold one or two columns each, and you can’t build a Division > Specialty hierarchy because a hierarchy can’t span tables.

Flatten it in Power Query or the source view instead:

Specialty Key TFC Code Specialty Division
1 100 General surgery Surgery
2 110 Trauma and orthopaedics Surgery
5 300 General medicine Medicine

The repeated division names cost almost nothing, because VertiPaq stores each distinct value once, in the column’s dictionary.

The review checklist

Before I call a healthcare activity model done:

  • Each fact table’s grain is written in its description, and admissions are counted at spell grain.
  • Snapshot facts have measures that pick a single snapshot, never a plain SUM over time.
  • Specialty, Site, Date and Demographics are shared by every fact that uses them, and no patient identifiers are loaded.
  • Every relationship is one-to-many and single-direction, and every inactive relationship has a measure that uses it.
  • There are no snowflaked dimension tables.

With that in place, the DAX gets simpler. The CALCULATE article shows what those filters do once they reach the facts.