← Methods repository
concept

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.

Outcome_Measureoutcome_measuremortality-ascertainmentoverall-survivaldeath-datenational-death-indexdeath-master-filecompeting-risksinformative-censoring
Methods reference only. Use primary source citations and local policy before applying this in a study protocol, regulatory submission, payer dossier, or clinical decision.

In plain language

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. (1) 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). (2) 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. (3) 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 to use

Any RWE/HEOR study with all-cause mortality or a composite that includes death; any survival, RMST, or cost-effectiveness analysis where person-time depends on correctly placing the death date; any oncology overall-survival endpoint, where regulators expect benchmarking to the NDI. Use it whenever your data span multiple settings (a patient seen in one EHR may die in another system's hospice) or whenever differential loss to follow-up by exposure arm is plausible.

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. (1) Require continuous Parts A/B FFS enrollment from index forward; flag and separately handle any switch to MA (where FFS claims stop). (2) 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. (3) 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). (4) 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. (5) 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. (6) 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. (7) 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.

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_idindex_dateNDI_death_dateDMF_death_datedischarge_death_date
P0012022-01-012022-08-142022-08-01NONE
P0022022-01-01NONE2022-11-012022-11-03
P0032022-01-01NONENONE2022-06-20
P0042022-01-01NONENONENONE

Steps

  • P001: NDI reports death on 2022-08-14. NDI is priority 1, so the assigned death date is 2022-08-14. The DMF record (2022-08-01) is noted but not used for the date because NDI outranks it. Follow-up ends on 2022-08-14.

  • P002: NDI has no record (perhaps the linkage file did not yet include this patient). DMF reports a death in November 2022 with month-year only, so the day is imputed to the 15th per the pre-specified rule: assigned date becomes 2022-11-15. The discharge code on 2022-11-03 is noted but DMF outranks it. Follow-up ends on 2022-11-15.

  • P003: Neither NDI nor DMF has a record, but the hospital discharge code on 2022-06-20 shows the patient died in the hospital. Under the union rule, this single source is enough to flag a death. Assigned death date is 2022-06-20. Follow-up ends on 2022-06-20.

  • P004: No source reports any death. The patient is treated as alive and their follow-up continues until the study end date or until they leave the insurance plan, whichever comes first. This administrative end-of-observation is the correct stopping point — NOT the date of their last doctor visit or last insurance claim.

  • Result check: 3 of 4 patients are flagged as dead (P001, P002, P003). Had the team relied on NDI alone, only P001 would have been captured, missing 2 deaths and artificially inflating apparent survival. Had the team used only discharge codes, P001 and P002 — both of whom died outside a hospital — would have been missed entirely.

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.

Runnable example

python implementation

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...

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"]]
r implementation

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 :...

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)]
}