Mortality Source Hierarchy
A pre-specified rule that combines multiple death-information sources (claims discharge status, EHR, registries, the Social Security Death Master File, the National Death Index, and linked vital records) into a single deterministic priority order to assign each patient a binary death flag and a single date of death for survival analysis.
On this page
A mortality source hierarchy is a written rule, decided before any data analysis begins, that tells a researcher which database to trust most when multiple sources disagree about whether a patient died and when. Because no single database catches every death, analysts combine several feeds — the National Death Index, insurance enrollment records, hospital discharge codes, and the Social Security Death Master File — and rank them from most to least reliable. The hierarchy picks the best available date for each patient and flags anyone missed by a lower-ranked source that a higher-ranked source caught. Without this rule, patients who die but whose deaths are invisible in one database can be silently treated as alive, which makes a drug look safer or more effective than it really is.
A mortality source hierarchy is the operational algorithm that turns several incomplete, partially overlapping death feeds into one analyzable endpoint: a death indicator and a single, defensible date of death per patient. No single real-world source captures all deaths completely or dates them exactly, so a composite that ranks sources by validity and fills gaps is now the standard of practice for any RWE study with a survival, all-cause-mortality, or time-to-event endpoint. The hierarchy must be written in protocol language before programming because every downstream quantity — follow-up time, censoring, hazard ratios, restricted mean survival time, cost-per-life-year — inherits its sensitivity, specificity, and date error.
Core conceptual distinction
Three things are being decided, and they are separable.
- Capture (the death flag): which sources count as evidence that a patient died, and in what priority. Sources differ in sensitivity (does it catch the death at all) and specificity (false positives — e.g., a discharge "expired" status keyed in error, or an SSDI record matched to the wrong person).
- Dating (the death date): once a death is established, which source's date is authoritative. The Death Master File and NDI are accurate to month/year but the day is unreliable when coded as the last day of the month; claims give a precise service date but only if death occurred during an observed encounter.
- Cause: all-cause vs cause-specific mortality is a different ascertainment problem entirely — cause requires NDI Plus / death- certificate ICD coding and cannot be read off enrollment files. The estimand must name which of these it needs. A naive "last claim = censor" default silently conflates no longer observed with alive, which is the single most common and most dangerous error this concept exists to prevent.
Pros, cons, and trade-offs
- vs a single-source death flag (e.g., enrollment-file death indicator alone): A hierarchy raises sensitivity dramatically — composite endpoints benchmarked to the NDI reach ~98% sensitivity versus ~83-92% for any one administrative source — and corrects systematically wrong dates. Cost: more code, more linkage agreements, and the need to reconcile disagreements between sources. Prefer the hierarchy for any consequential effectiveness, safety, or economic analysis where missed or misdated deaths bias the result.
- vs "high-specificity only" (require ≥2 concordant sources before flagging a death): Demanding agreement maximizes specificity and is appropriate when a false death is catastrophic (e.g., an automated safety signal). Cost: it sacrifices sensitivity and undercounts deaths that only one good source saw. Prefer concordance rules for confirmatory/regulatory deliverables; prefer the union/ priority hierarchy when undercounting deaths is the worse error (most survival analyses).
- vs treating end-of-enrollment as the endpoint (administrative censoring only): Censoring at disenrollment is defensible only if disenrollment is unrelated to death. It usually is not — sick patients disenroll, switch to hospice, or move to Medicare Advantage where fee-for-service claims vanish. Substituting censoring for an actual death feed induces informative censoring and inflates survival. Prefer a real mortality source whenever a death index or linked vital record is obtainable.
When NOT to use — and when it is actively misleading or dangerous
- When you need cause-specific mortality but only have all-cause feeds. A hierarchy of enrollment + SSDI + discharge status tells you that a patient died, never why. Reporting "cardiovascular death" off such sources is fabrication; you need death-certificate cause coding (NDI Plus) or adjudication.
- When false positives dominate the decision. For an automated pharmacovigilance trigger, a union-style hierarchy that flags on any single source will fire on mismatched SSDI links and erroneous discharge codes; require concordance or manual review instead.
- When the date error interacts with a short-window estimand. If the estimand is 30-day mortality and your authoritative date is month/year-only (snapped to mid-month or month-end), the day-level error can flip events across the window boundary. Do not paper over month-only dates with a fixed imputation and then estimate a day-resolution endpoint.
- When disenrollment and death are confounded and you censor instead of ascertaining. Using end-of-coverage as a pseudo-death or as the only censoring event when sick patients drop coverage makes the treated arm look immortal. This is the failure mode that most often produces a spuriously protective drug effect.
Data-source operational depth
- Claims (FFS vs Medicare Advantage): Death is inferable from the inpatient discharge status code (`DSTATUS`/`disp` = expired) and, in Medicare, from the enrollment/denominator death indicator and `death_date` (DMF-sourced). Failure modes: (a) MA-only person-time lacks adjudicated FFS claims, so in-hospital deaths are invisible and "no death" can be pure missingness — restrict mortality follow-up to A/B-enrolled FFS time or link to a death index; (b) out-of-hospital deaths (home, hospice, ED-on-arrival) never generate a discharge status; (c) discharge "expired" can be miscoded, creating false positives; (d) claims lag and reversals mean the death date is not final for months. Workaround: take the date from the enrollment/DMF feed, use discharge status as a sensitivity-raising supplement, and never censor MA-only person-time as if it were observed alive.
- EHR: Death is captured only if it happens inside, or is reported back to, the network — a patient who leaves the system and dies elsewhere is silently censored. Structured `death_date`/`deceased_flag` fields are encounter-driven and notoriously incomplete; obituary and SSDI augmentation recover large numbers of out-of-network deaths. Failure mode: differential capture by exposure if one arm is sicker and referred out. Workaround: link to commercial claims and a death index; report capture completeness by site and by arm.
- Registry: Disease registries (e.g., SEER, cancer registries) often have actively followed, adjudicated vital status and cause of death, but linkage eligibility and lag limit completeness and the followed population may not be transportable to the analysis cohort. Workaround: link to claims for the interval between registry contacts; document the linkage denominator.
- Linked claims-EHR-vital records (DMF/NDI): The reference substrate. The National Death Index is the benchmark for both fact and cause of death; the Death Master File / SSDI is broad but lost many state-reported deaths after the 2011 records-access changes (a known sensitivity drop for recent years). Failure modes: linkage selection (only the linkable subset), false matches (specificity), and month/year-only dates. Workaround: probabilistic-match QC, benchmark composite sensitivity/specificity to the NDI, and pre-specify a date-imputation rule for month-only records.
Estimand and competing-risks note
With death as the outcome, all-cause mortality is a single risk and a Cox/pooled-logistic model on the hierarchy-derived flag/date is appropriate. With a non-fatal primary outcome (e.g., first hospitalization for heart failure), death is a competing risk and the choice of estimand is consequential: the cause-specific hazard (treat death as censoring; `PROC PHREG`, `coxph`) answers an etiologic question, whereas the subdistribution hazard / cumulative incidence function (Fine-Gray; `PROC PHREG eventcode=`, `cmprsk`/`PROC LIFETEST plots=cif`) answers an absolute- risk/decision question. In elderly claims populations, mortality is high and may differ by exposure, so Kaplan-Meier on the non-fatal event overstates its incidence; pre-specify cause-specific vs subdistribution and report the CIF.
Worked claims example
Goal: an all-cause mortality endpoint for a 100% Medicare FFS cohort of new initiators, index_date = first qualifying fill.
- Require continuous Parts A/B FFS enrollment from index forward; flag and separately handle any switch to MA (where FFS claims stop).
- Build candidate death records from three feeds: the enrollment/denominator `death_date` (DMF-sourced), inpatient claims where `discharge_status` ∈ expired codes (take the claim `thru_date` as the date), and a linked NDI/state-file date where available.
- Apply the priority hierarchy for the flag: a patient is dead if ANY source indicates death (union maximizes sensitivity), but require that the death date fall on or after index and within enrollment (drop physiologically impossible records).
- Assign the date by priority: NDI > enrollment/DMF > inpatient discharge `thru_date`; if only month/year is available, pre-specify imputation (e.g., 15th of month) and carry a flag for sensitivity analysis.
- Reconcile conflicts: if two sources disagree by >X days, log and review; if discharge "expired" appears but no death is in DMF within 90 days, treat as a possible false positive in a high-specificity sensitivity run.
- Define follow-up from index to the assigned death date or to administrative censoring (end of A/B FFS enrollment, end of data, or MA switch) — and crucially, do NOT censor at the last observed claim, which would convert unobserved deaths into spurious survival.
- Sensitivity analyses: vary the date-imputation rule, swap the union flag for a ≥2-source concordance flag, and report endpoint sensitivity/specificity against the NDI benchmark where the linkage exists.
Interpreting the output
From the worked example: 4 patients; hierarchy yields 3 deaths (P001 NDI 2022-08-14, P002 DMF 2022-11-15 day-imputed, P003 discharge 2022-06-20); P004 alive. NDI alone would capture only P001 (1/3 true deaths, 33%). Discharge-status alone captures only P003 (33%); P001 and P002 died outside hospital. Composite union flag captures all 3 (100% in this small example).
(1) Formal interpretation. The hierarchy assigns one death flag per patient (union of all sources) and one authoritative death date (priority: NDI > DMF > discharge > inferred). For P001, NDI date 2022-08-14 overrides the DMF date 2022-08-01 per the pre-specified rule, giving follow-up 225 days. For P002, DMF is the sole source and day-imputation to the 15th is documented as a sensitivity flag. For P003, discharge "expired" with no NDI or DMF match is accepted at face value, giving follow-up 170 days. P004 is censored at administrative end of enrollment. Single-source analyses would systematically misclassify P001 and P002 as censored, inflating apparent survival times for those patients by the interval between last claim and true death.
(2) Practical interpretation. A Kaplan-Meier or Cox model using only discharge-status deaths would lose P001 and P002 from the risk set as events, instead censoring them — inflating survival estimates and potentially biasing treatment comparisons if mortality undercount is differential by arm (e.g., Drug B patients more likely to die in non-acute settings). Conversely, NDI-only linkage misses P002 and P003. The composite hierarchy maximizes completeness while the date-priority rule minimizes measurement error in follow-up time, both of which are essential for unbiased survival analysis.
Decision diagram
flowchart TD
P[Patient in cohort<br/>index_date assigned] --> NDI{Linked NDI /<br/>state vital record?}
NDI -- yes --> DT[Death date = NDI date<br/>source priority 1]
NDI -- no --> DMF{Enrollment / DMF<br/>death_date present?}
DMF -- yes --> IMP[Snap month/year-only<br/>to pre-specified day] --> DT2[Death date = DMF date<br/>priority 2]
DMF -- no --> DIS{Inpatient discharge<br/>status = expired?}
DIS -- yes --> DT3[Death date = claim thru_date<br/>priority 3]
DIS -- no --> ALIVE[No death evidence<br/>administrative censor at end of FFS enrollment<br/>NOT at last claim]
DT --> VAL{Date on/after index<br/>and within enrollment?}
DT2 --> VAL
DT3 --> VAL
VAL -- yes --> DEAD[death_flag = 1<br/>follow-up ends at death date]
VAL -- no --> DROP[Drop impossible record<br/>fall through to next source]flowchart LR
subgraph Sources [Death feeds, ranked by validity]
S1[NDI / linked vital record<br/>best fact + cause + date]
S2[Enrollment / DMF death_date<br/>broad capture, month-level date]
S3[Inpatient discharge = expired<br/>precise date, in-hospital only]
S4[EHR deceased flag<br/>in-network deaths only]
end
S1 --> H[Mortality source hierarchy<br/>union flag + priority date]
S2 --> H
S3 --> H
S4 --> H
H --> EST{Estimand?}
EST -- death is the outcome --> AC[All-cause mortality<br/>Cox / pooled logistic]
EST -- non-fatal outcome,<br/>death competes --> CR[Cause-specific hazard<br/>vs subdistribution / CIF Fine-Gray]Worked example
Scenario
Four patients in a drug study all started treatment on January 1, 2022. The study team has three death data sources: the NDI (gold standard, but slow — only available through December 2022), the DMF (broad coverage, but records death month and year only, not the exact day), and hospital discharge codes (exact date, but only catches deaths that happen during a hospital stay). The hierarchy rule is: NDI first, then DMF, then discharge codes. A patient is flagged as dead if any source reports a death (union rule). The date is taken from the highest-priority source that reported a death for that patient.
Dataset
Raw death signals across three sources for four study patients. Each cell shows the reported date of death, or NONE if the source has no record.
| person_id | index_date | NDI_death_date | DMF_death_date | discharge_death_date |
|---|---|---|---|---|
| P001 | 2022-01-01 | 2022-08-14 | 2022-08-01 | NONE |
| P002 | 2022-01-01 | NONE | 2022-11-01 | 2022-11-03 |
| P003 | 2022-01-01 | NONE | NONE | 2022-06-20 |
| P004 | 2022-01-01 | NONE | NONE | NONE |
Steps
Result
Deaths captured: 3 out of 4 patients (P001, P002, P003). Assigned dates: P001 = 2022-08-14 (NDI), P002 = 2022-11-15 (DMF with day imputed to 15th), P003 = 2022-06-20 (discharge code, the only source). P004 remains alive in the analysis. Using only a single source would have missed at least 1 and up to 2 of these 3 real deaths, shortening apparent follow-up for survivors and biasing any comparison between treatment groups.
Trade-offs
Runnable example
Build an all-cause-mortality endpoint from multiple death feeds via a priority hierarchy. Required inputs (cleaned, one concept each; dates are datetime): cohort : person_id, index_date enroll : person_id, ffs_start, ffs_end # FFS A/B-observable mortality follow-up spans dmf : person_id, death_date, date_precision #...
import pandas as pd
import numpy as np
EXPIRED_CODES = {"20", "40", "41", "42"} # site-specific: discharge_status = expired
MONTH_IMPUTE_DAY = 15 # pre-specified day for month/year-only dates
def build_mortality_endpoint(cohort, enroll, dmf, inp, ndi):
idx = cohort[["person_id", "index_date"]]
# --- Candidate death records from each feed, each tagged with a source priority. ---
d_ndi = ndi[["person_id", "death_date"]].assign(source="ndi", priority=1)
d_dmf = dmf.copy()
impute = d_dmf["date_precision"].eq("month")
d_dmf.loc[impute, "death_date"] = (
d_dmf.loc[impute, "death_date"].values.astype("datetime64[M]")
+ np.timedelta64(MONTH_IMPUTE_DAY - 1, "D"))
d_dmf = d_dmf[["person_id", "death_date"]].assign(source="dmf", priority=2)
d_inp = (inp[inp["discharge_status"].astype(str).isin(EXPIRED_CODES)]
.rename(columns={"thru_date": "death_date"})[["person_id", "death_date"]]
.assign(source="discharge", priority=3))
cand = pd.concat([d_ndi, d_dmf, d_inp], ignore_index=True).merge(idx, on="person_id")
# --- Validity filter: death must be on/after index and within FFS-observable enrollment. ---
cand = cand.merge(enroll, on="person_id")
cand = cand[(cand["death_date"] >= cand["index_date"]) &
(cand["death_date"] >= cand["ffs_start"]) &
(cand["death_date"] <= cand["ffs_end"])]
# --- FLAG (union): dead if ANY valid source fired. DATE: take highest-priority source. ---
cand = cand.sort_values(["person_id", "priority", "death_date"])
dead = (cand.groupby("person_id")
.first()
.reset_index()[["person_id", "death_date", "source"]]
.rename(columns={"source": "date_source"}))
out = idx.merge(dead, on="person_id", how="left")
out["death_flag"] = out["death_date"].notna().astype(int)
# --- Follow-up end: death date if dead, else administrative censor at end of FFS enrollment. ---
ffs_end = enroll.groupby("person_id")["ffs_end"].max().rename("ffs_end")
out = out.merge(ffs_end, on="person_id", how="left")
out["fup_end"] = out["death_date"].fillna(out["ffs_end"]) # never censor at last claim
out["fup_days"] = (out["fup_end"] - out["index_date"]).dt.days
return out[["person_id", "index_date", "death_flag", "death_date",
"date_source", "fup_end", "fup_days"]]All-cause-mortality endpoint from multiple death feeds via a priority hierarchy (data.table). Inputs mirror the Python version: cohort : person_id, index_date (Date) enroll : person_id, ffs_start, ffs_end (Date) # FFS-observable follow-up spans dmf : person_id, death_date (Date), date_precision in {'day','month'}...
library(data.table)
EXPIRED_CODES <- c("20", "40", "41", "42") # discharge_status = expired (site-specific)
MONTH_IMPUTE_DAY <- 15L # pre-specified day for month/year-only dates
build_mortality_endpoint <- function(cohort, enroll, dmf, inp, ndi) {
setDT(cohort); setDT(enroll); setDT(dmf); setDT(inp); setDT(ndi)
idx <- cohort[, .(person_id, index_date)]
d_ndi <- ndi[, .(person_id, death_date, source = "ndi", priority = 1L)]
d_dmf <- copy(dmf)
mo <- d_dmf$date_precision == "month"
d_dmf[mo, death_date := as.Date(format(death_date, "%Y-%m-01")) + (MONTH_IMPUTE_DAY - 1L)]
d_dmf <- d_dmf[, .(person_id, death_date, source = "dmf", priority = 2L)]
d_inp <- inp[as.character(discharge_status) %chin% EXPIRED_CODES,
.(person_id, death_date = thru_date, source = "discharge", priority = 3L)]
cand <- rbindlist(list(d_ndi, d_dmf, d_inp))[idx, on = "person_id", nomatch = 0L]
# Validity: death on/after index and within FFS-observable enrollment.
cand <- enroll[cand, on = "person_id", nomatch = 0L]
cand <- cand[death_date >= index_date & death_date >= ffs_start & death_date <= ffs_end]
# FLAG = union; DATE = highest-priority source per person.
setorder(cand, person_id, priority, death_date)
dead <- cand[, .(death_date = death_date[1L], date_source = source[1L]), by = person_id]
out <- dead[idx, on = "person_id"]
out[, death_flag := as.integer(!is.na(death_date))]
ffs_end <- enroll[, .(ffs_end = max(ffs_end)), by = person_id]
out <- ffs_end[out, on = "person_id"]
out[, fup_end := fifelse(is.na(death_date), ffs_end, death_date)] # not last claim
out[, fup_days := as.integer(fup_end - index_date)]
out[, .(person_id, index_date, death_flag, death_date, date_source, fup_end, fup_days)]
}All-cause-mortality endpoint via a priority hierarchy, then a competing-risks-aware survival fit. Required input datasets (post data-management): work.cohort : person_id, index_date work.enroll : person_id, ffs_start, ffs_end (FFS-observable follow-up spans) work.dmf : person_id, death_date, date_precision...
%let impute_day = 15; /* pre-specified day for month/year-only dates */
/* Candidate death records from each feed, tagged with source priority (1=best). */
data cand;
set work.ndi(in=n keep=person_id death_date)
work.dmf(in=d keep=person_id death_date date_precision)
work.inp(in=i keep=person_id thru_date discharge_status);
length source $9;
if n then do; source='ndi'; priority=1; end;
else if d then do;
source='dmf'; priority=2;
/* snap month/year-only DMF dates to a pre-specified day */
if date_precision='month' then
death_date = mdy(month(death_date), &impute_day, year(death_date));
end;
else if i then do;
source='discharge'; priority=3; death_date = thru_date;
if discharge_status not in ('20','40','41','42') then delete; /* expired codes only */
end;
keep person_id death_date source priority;
run;
/* Validity filter: death on/after index and within FFS-observable enrollment. */
proc sql;
create table cand_ok as
select c.person_id, c.death_date, c.source, c.priority
from cand c
inner join work.cohort co on c.person_id = co.person_id
inner join work.enroll e on c.person_id = e.person_id
where c.death_date >= co.index_date
and c.death_date between e.ffs_start and e.ffs_end;
quit;
/* FLAG = union (any source); DATE = highest-priority source per person. */
proc sort data=cand_ok; by person_id priority death_date; run;
data dead;
set cand_ok; by person_id;
if first.person_id; /* keep best-priority, earliest-date record */
rename source=date_source;
run;
/* Assemble endpoint: never censor at last claim - censor at end of FFS enrollment. */
proc sql;
create table endpoint as
select co.person_id, co.index_date,
case when d.death_date is not null then 1 else 0 end as death_flag,
d.death_date, d.date_source,
coalesce(d.death_date, e.ffs_end) as fup_end format=date9.,
(calculated fup_end) - co.index_date as fup_days
from work.cohort co
left join dead d on co.person_id = d.person_id
left join (select person_id, max(ffs_end) as ffs_end from work.enroll
group by person_id) e on co.person_id = e.person_id;
quit;
/* All-cause-mortality Cox model (death = event). For a NON-fatal primary outcome where death
competes, add eventcode= to get the Fine-Gray subdistribution hazard / CIF instead. */
proc phreg data=work.analytic; /* analytic = endpoint joined to exposure + covariates */
class arm(ref='COMPARATOR');
model fup_days*event_type(0) = arm <covariates> / eventcode=1 risklimits; /* 1=outcome, 2=death */
run;Citations
- [1]Curtis MD, Griffith SD, Tucker M, et al. Development and validation of a high-quality composite real-world mortality endpoint. Health Services Research. 2018;53(6):4460-4476.
- [2]Austin PC, Lee DS, Fine JP. Introduction to the analysis of survival data in the presence of competing risks. Circulation. 2016;133(6):601-609.