Person-Time Denominator Construction
The rule for accumulating each subject's at-risk follow-up time — from a defined origin to the first of event, competing event, or censoring — and splitting it across time scales to form the denominator of an incidence rate.
On this page
When a study follows people over time to count new disease events, the denominator of the rate is not the number of people — it is the total amount of time all those people were actually being watched and were at risk of having the event. Each participant contributes days from the moment they enter the study until they either have the event, leave the study, or the data run out; those individual stretches of time are then added together to form the denominator. This total accumulated follow-up time (called the time-at-risk) is expressed in person-days or person-years, and dividing the event count by it gives the incidence rate. The approach handles real-world messiness naturally, because people who are followed for different lengths of time each contribute only as much time as they were actually observed.
Person-time denominator construction
is the operational machinery that turns a cohort of subjects into the denominator of a rate: it decides, for every individual, when their clock starts, when it stops, and which intervals of calendar, age, and time-since-exposure that follow-up gets credited to. The numerator of an incidence rate is a count of events; the denominator is the sum of at-risk time over everyone who could have had the event. Get the time-at-risk wrong and the rate is wrong, regardless of how cleanly the outcome was ascertained. In RWE this is rarely a one-line `time = exit - entry` calculation — it is enrollment spans intersected with exposure episodes intersected with the at-risk window, then Lexis-split so that a piecewise-constant rate can be estimated on the right scale.
Core conceptual distinction
. Three anatomical decisions define the denominator and are separable.
- Origin (time zero): the index/treatment-initiation date for an incident-user analysis, or diagnosis/enrollment for a natural-history cohort. Person-time accrued before the exposure could possibly act must not be credited to the exposure — doing so is the mechanism of immortal time bias, where survivors are guaranteed event-free until they qualify and that "immortal" span is misallocated to the treated denominator, deflating its rate.
- Exit and the censoring hierarchy: follow-up ends at the first of the outcome event, a competing event (e.g., death when the outcome is a non-fatal hospitalization), administrative end-of-data, loss of observability (disenrollment), and — for an as-treated estimand — treatment discontinuation or switch. Which exits stop the clock versus count as the event is an estimand decision, not a programming detail.
- Time-scale splitting (the Lexis diagram): the hazard is almost never constant on a single scale, so a person's interval is cut at age-band boundaries, calendar-year boundaries, and time-since-initiation boundaries, producing multiple (tstart, tstop, event) rows per person. Each row contributes its person-time to one cell; rates are then estimated per cell or modeled with these counting-process records (`Surv(tstart, tstop, event)`), which is also exactly the input a time-dependent Cox model consumes. The estimand distinction matters: an intention-to-treat (initiation) denominator credits all post-time-zero observable time to the assigned arm regardless of later switching; an as-treated denominator credits only on-treatment time (days_supply stitched with grace periods, censored at discontinuation/switch) and usually requires inverse-probability-of-censoring weighting because discontinuation is informative.
Pros, cons, and trade-offs
.
- vs a naive `exit - entry` single-interval denominator: Explicit interval construction (enrollment ∩ exposure ∩ at-risk) prevents crediting unobservable or post-exit time and prevents immortal time. Cost: substantially more programming and diagnostics. Prefer interval construction for any rate that informs a regulatory or HTA decision.
- vs unsplit person-time with a single average rate: Lexis splitting lets the rate vary by age, calendar time, and time-since-exposure and is mandatory for direct/indirect standardization and for time-dependent hazard models. Cost: a larger, multi-row analytic table and care that no person-time is double-counted across cells. Prefer splitting whenever the rate plausibly changes on a scale you care about (it almost always does with age and follow-up duration).
- vs cumulative-incidence (risk) denominators: A rate denominator (person-time) handles staggered entry, variable follow-up, and censoring naturally and is the right tool when follow-up length differs across subjects; a risk denominator (persons at baseline) answers "what fraction by time t" and needs competing-risk handling (cumulative incidence function). Prefer person-time rates for sparse events with heterogeneous follow-up; prefer risk/CIF when an absolute probability over a fixed horizon is the decision-relevant quantity and competing events are common.
When NOT to use — and when it is actively misleading or dangerous
.
- When the decision quantity is an absolute probability over a fixed horizon with substantial competing risk. A rate per 1,000 person-years can be multiplied into a misleading "risk" by ignoring that death removes people from the at-risk set; report the cumulative incidence function instead. In elderly claims cohorts where the competing risk of death differs by exposure arm, naive rate comparisons are actively misleading about absolute burden.
- When immortal time is being silently credited. If time zero is set at diagnosis but exposure is defined by a later prescription, the gap is immortal and must either be excluded, treated as unexposed, or handled with a time-dependent exposure. Crediting it to the exposed denominator manufactures a spurious protective effect — the classic Suissa failure mode in procedure and prescription studies.
- When the denominator includes unobservable time. Counting calendar time during which the data source cannot see events (a disenrolled gap, an MA-only span with no fee-for-service claims) inflates the denominator and deflates the rate. This is a denominator error masquerading as a low event rate.
- When prevalent subjects enter with unaccounted left truncation. Registry cohorts that enroll prevalent cases start the clock after the origin; treating them as if observed from origin biases rate estimates (late-entry / delayed-entry must be modeled).
Data-source operational depth
.
- Claims (FFS): Person-time is the intersection of continuous medical/pharmacy enrollment spans with the at-risk window. Require continuous enrollment so that absence of a claim is true absence, not unobservability. Failure modes: (a) Medicare Advantage (MA)-only spans carry no fee-for-service claims — events are invisible there, so that calendar time must be removed from the denominator (censor at MA entry), not assumed event-free; drop the person-time, not the person. (b) Plan-switch and coverage gaps create unobservable intervals — split the at-risk interval at the gap and exclude it. (c) Adjudication/run-out lag means the most recent months of claims are incomplete; truncate the study end before the lag horizon or person-time near the end is under-eventful. (d) 90-day mail-order and stockpiling distort as-treated on-treatment windows and grace-period stitching.
- EHR: Capture is visit-driven, so absence of an encounter is not evidence of no event — a patient who silently leaves the system contributes apparent event-free person-time that is really unobserved. Define an observability window (e.g., recent encounter within N months) and treat loss to follow-up as potentially informative censoring; link to pharmacy/claims to confirm exposure episodes.
- Registry: Strong for adjudicated outcomes and severity but often enrolls prevalent subjects, inducing left truncation; the at-risk clock must start at the true origin with delayed entry, and registry completeness/linkage eligibility define which person-time is observable.
- Linked claims–EHR–vital records: The ideal substrate, but order, fill, and service dates disagree; reconcile them before assigning time zero and interval boundaries, and recognize that only the linkable subset contributes person-time (linkage selection).
Worked claims example
Question: incidence rate of acute kidney injury (AKI) among new SGLT2 inhibitor initiators aged ≥65 in fee-for-service Medicare.
- Origin / time zero: first SGLT2i fill (NDC + `fill_date`), with ≥365 days of continuous Parts A/B/D FFS enrollment before it (washout confirms incident use and observability).
- At-risk start: time zero.
- At-risk exit = first of: validated AKI dx (the event), death (a competing event — stops the clock, not counted as AKI), disenrollment or MA entry (censor — unobservable thereafter), end of data minus the 3-month adjudication lag, and — for the as-treated denominator — last `days_supply` end + a 30-day grace period or switch to another antidiabetic.
- Compute person-time: for each person, `person_days = at_risk_exit - time_zero` over observable, on-treatment spans, split at each calendar-year and age-band boundary.
- Arithmetic: suppose across the cohort there are 240 first AKI events and the summed observable on-treatment time is 511,000 person-days. Person-years = 511,000 / 365.25 = 1,398.8. Incidence rate = 240 / 1,398.8 × 1,000 = 171.6 per 1,000 person-years (95% CI from the Poisson/exact interval). Note how excluding MA-only and disenrolled spans lowers the denominator and raises the rate versus a naive `enroll_end - time_zero` denominator that would have silently counted unobservable time.
Interpreting the output
Denominator construction for a 4-patient cohort yields 729 observable at-risk person-days (90 + 333 + 184 + 122), with 2 events; the resulting incidence rate denominator is 729 person-days (approximately 2.0 person-years).
Formal interpretation. Each patient contributes person-time only during intervals satisfying three conditions:
- active enrollment in a plan type with observable claims — Medicare FFS or commercial; MA-only periods are excluded because utilization is unobservable;
- the patient has not yet experienced the outcome; and
- the observation falls within the at-risk window defined by time zero and censoring rules. The 729-person-day total is the denominator for the incidence rate, not the 4-patient head count. Patients contribute different amounts of time because of staggered entry, variable follow-up, and administrative censoring. Lexis-splitting at calendar-year and age-band boundaries enables age- and calendar-stratified rates from the same denominator dataset.
Practical interpretation. Misspecifying the denominator — for example, using enrollment end rather than the MA-exclusion-aware at-risk exit date — silently inflates observed time and underestimates the true event rate. The correct 729-person-day denominator is the foundation of any subsequent incidence rate, rate ratio, or standardized rate calculation. Report total person-time, mean and range of individual follow-up, and the number and reason for each type of censoring so that reviewers can audit the at-risk construction independently.
Decision diagram
flowchart TD
T0[Origin / time zero<br/>e.g. first qualifying fill] --> Clip[At-risk window: time zero -> follow-up]
Enr[Continuous FFS enrollment spans<br/>drop MA-only and gap spans] --> Int[Intersect: enrollment INTERSECT at-risk]
Clip --> Int
Int --> Exit{First exit?}
Exit -->|Outcome event| Ev[Event: count in numerator,<br/>stop clock]
Exit -->|Competing event / death| Cmp[Censor: stop clock,<br/>NOT counted as event]
Exit -->|Disenroll / MA entry / gap| Cen[Censor: time unobservable<br/>excluded from denominator]
Exit -->|End of data minus run-out| Adm[Administrative censor]
Ev --> Split[Lexis split at age x calendar x<br/>time-since-initiation boundaries]
Cmp --> Split
Cen --> Split
Adm --> Split
Split --> Rate[Sum person-time per cell -><br/>rate = events / person-time]gantt title Person-time for one subject (claims) — observable, on-treatment at-risk time dateFormat YYYY-MM-DD axisFormat %b %Y section Pre-time-zero 365-day washout + continuous FFS enrollment :done, wash, 2022-01-01, 2022-12-31 section At-risk (counts toward denominator) Year 2023 person-time (FFS, on-treatment) :active, py1, 2023-01-01, 2023-12-31 Year 2024 person-time (FFS, on-treatment) :active, py2, 2024-01-01, 2024-04-15 section Excluded from denominator MA-only span (no FFS claims -> unobservable) :crit, ma, 2024-04-15, 60d section Exit First AKI event = numerator, clock stops :milestone, ev, 2024-06-14, 0d
Worked example
Scenario
A claims database study is tracking new users of a blood-pressure drug to measure how often they develop a kidney problem (the outcome event) in 2023. Four patients start the drug at different times during the year. The analyst needs to figure out how much time each patient was actually being observed and at risk, then sum those amounts to build the denominator of the incidence rate.
Dataset
One row per patient showing their entry date, the date their follow-up ended, why it ended, and the number of days they were at risk (exit date minus entry date).
| person_id | entry_date | exit_date | exit_reason | days_at_risk |
|---|---|---|---|---|
| 1001 | 2023-01-01 | 2023-04-01 | outcome event | 90 |
| 1002 | 2023-02-01 | 2023-12-31 | study period ended (administrative) | 333 |
| 1003 | 2023-03-01 | 2023-09-01 | lost insurance coverage (censored) | 184 |
| 1004 | 2023-06-01 | 2023-10-01 | outcome event | 122 |
Steps
Result
Total time-at-risk = 90 + 333 + 184 + 122 = 729 person-days. Two events occurred. Incidence rate = 2 events / 729 person-days.
Trade-offs
Runnable example
Build observable, on-treatment at-risk intervals and Lexis-split them by calendar year, then estimate the incidence rate. Python has no native Lexis splitter, so the interval logic is explicit (a teaching point in itself).
import pandas as pd
import numpy as np
from scipy.stats import chi2 # exact Poisson CI
def observable_at_risk(cohort: pd.DataFrame, enroll: pd.DataFrame) -> pd.DataFrame:
"""Intersect [time_zero, exit_date) with FFS-observable (non-MA) enrollment spans."""
e = enroll[~enroll["ma_only"]].merge(
cohort[["person_id", "time_zero", "exit_date", "event"]], on="person_id")
# Clip each enrollment span to the at-risk window.
e["seg_start"] = e[["enroll_start", "time_zero"]].max(axis=1)
e["seg_end"] = e[["enroll_end", "exit_date"]].min(axis=1)
e = e[e["seg_end"] > e["seg_start"]].copy()
# The event only counts on the segment that actually contains exit_date.
e["event"] = np.where((e["event"] == 1) & (e["seg_end"] == e["exit_date"]), 1, 0)
return e[["person_id", "seg_start", "seg_end", "event"]]
def split_calendar_year(seg: pd.DataFrame) -> pd.DataFrame:
"""Lexis split each observable segment at Jan-1 boundaries -> one row per (person, year)."""
rows = []
for r in seg.itertuples(index=False):
start, end = r.seg_start, r.seg_end
for yr in range(start.year, end.year + 1):
lo = max(start, pd.Timestamp(yr, 1, 1))
hi = min(end, pd.Timestamp(yr + 1, 1, 1))
if hi <= lo:
continue
ev = int(r.event == 1 and hi == end) # event flag only on terminal sub-row
rows.append((r.person_id, yr, (hi - lo).days, ev))
return pd.DataFrame(rows, columns=["person_id", "cal_year", "person_days", "event"])
def incidence_rate(split: pd.DataFrame, per: int = 1000) -> pd.DataFrame:
g = split.groupby("cal_year").agg(events=("event", "sum"),
person_days=("person_days", "sum")).reset_index()
g["person_years"] = g["person_days"] / 365.25
g["rate"] = g["events"] / g["person_years"] * per
# Exact (Garwood) Poisson 95% CI on the count, rescaled to PY.
lo = chi2.ppf(0.025, 2 * g["events"]) / 2
hi = chi2.ppf(0.975, 2 * (g["events"] + 1)) / 2
g["lcl"] = lo / g["person_years"] * per
g["ucl"] = hi / g["person_years"] * per
return g
seg = observable_at_risk(cohort, enroll)
split = split_calendar_year(seg)
print(incidence_rate(split))Lexis-split person-time with survival::survSplit, then estimate the rate with a Poisson model on log person-time (the standard rate model) and feed the same counting-process records to a time-dependent Cox model.
library(survival)
## Follow-up time in years from time zero; counting-process form (tstart, tstop, event).
cohort$fu_years <- as.numeric(cohort$texit - cohort$t0) / 365.25
cohort$tstart <- 0
## Lexis split on the follow-up time scale at 1-year cut points (also splittable on age via a second call).
cuts <- seq(0, ceiling(max(cohort$fu_years)), by = 1)
split <- survSplit(Surv(fu_years, event) ~ ., data = cohort,
cut = cuts, start = "tstart", end = "tstop", episode = "fu_band")
split$pyears <- split$tstop - split$tstart # person-years contributed by this sub-interval
## Crude rate per 1,000 PY with an exact Poisson interval.
pt <- sum(split$pyears); d <- sum(split$event)
rate <- d / pt * 1000
ci <- poisson.test(d, T = pt)$conf.int * 1000
cat(sprintf("IR = %.1f per 1,000 PY (95%% CI %.1f-%.1f)\n", rate, ci[1], ci[2]))
## Rate model: Poisson with log person-time offset (rate ratio by arm and follow-up band).
fit_rate <- glm(event ~ arm + factor(fu_band) + offset(log(pyears)),
family = poisson, data = split[split$pyears > 0, ])
## Same denominator, hazard model: time-dependent Cox on the counting-process rows.
fit_cox <- coxph(Surv(tstart, tstop, event) ~ arm, data = split)Data-step Lexis splitter producing (tstart, tstop, event, cal_year, age_band) counting-process rows, then PROC STDRATE for age-standardized rates with explicit person-time and PROC PHREG for the time-dependent hazard on the same records.
/* Lexis split at calendar-year and 5-year age boundaries; one row per sub-interval, event only on the terminal row. */
data split;
set work.cohort;
seg_start = t0;
do until (seg_start >= texit);
cy = year(seg_start);
yr_bound = mdy(1, 1, cy + 1); /* next Jan 1 */
age_now = age0 + (seg_start - t0) / 365.25;
age_bound = t0 + ceil((floor(age_now/5)*5 + 5 - age0) * 365.25); /* next 5-yr age cut */
seg_end = min(texit, yr_bound, age_bound);
cal_year = cy;
age_band = floor(age_now / 5) * 5;
py = (seg_end - seg_start) / 365.25; /* person-years in this sub-interval */
ev = (seg_end = texit) * event; /* event flag only on the terminal sub-row */
tstart = (seg_start - t0) / 365.25;
tstop = (seg_end - t0) / 365.25;
if py > 0 then output;
seg_start = seg_end;
end;
format seg_start seg_end date9.;
run;
/* Collapse to event counts and person-time per stratum for a rate / standardized rate. */
proc summary data=split nway;
class arm age_band;
var ev py;
output out=rates(drop=_type_ _freq_) sum(ev)=events sum(py)=pyears;
run;
/* Direct age-standardization of the incidence rate (rate = events / person-time) by arm. */
proc stdrate data=rates refdata=work.std_pop
method=direct stat=rate(mult=1000) plots=none;
population group=arm event=events pyears=pyears; /* PYEARS= is PERSON-TIME (TOTAL= is for STAT=RISK) */
reference total=ref_pop;
strata age_band;
run;
/* Same denominator, hazard model: time-dependent Cox using the counting-process (tstart, tstop) rows. */
proc phreg data=split;
class arm(ref='COMPARATOR');
model (tstart, tstop)*ev(0) = arm / rl ties=efron;
run;Citations
- [1]Suissa S. Immortal time bias in pharmacoepidemiology. American Journal of Epidemiology. 2008;167(4):492-499.
- [2]Hernán MA. How to estimate the effect of treatment duration on survival outcomes using observational data. BMJ. 2018;360:k182.