OMOP Time-at-Risk and Cohort Exit
The OMOP/OHDSI cohort-logic specification of when each subject's outcome follow-up begins (time-at-risk start), when it ends (cohort exit / censoring), and how the at-risk window is anchored to cohort entry, observation period, exposure era, and death, which jointly determine the denominator (person-time) and the eligible numerator (outcome) events.
On this page
When researchers study whether a drug causes a health event, they need to define exactly which days each patient was being watched for that event -- this span is called the time-at-risk window. The window opens at the patient's starting point (usually the day they first took the drug, sometimes one day later to exclude events already present on that day) and closes at whichever comes first: a fixed number of follow-up days, the day the patient lost insurance coverage, or the day the outcome actually occurred. Getting these boundaries right lets the study count real new events rather than old ones, and avoids crediting a drug with event-free time that happened before anyone was even taking it.
In the OHDSI/OMOP framework a cohort is a set of persons who satisfy entry criteria for a span of time, and the analytic estimate is generated only over the time-at-risk (TAR) window attached to each cohort entry. TAR is defined by two offsets relative to anchor dates: a start rule (e.g., `cohort_start_date + start_date_offset`, often offset 0 = exposure initiation, or +1 day to exclude prevalent outcomes coded on the index day) and an end rule (`cohort_end_date`, `observation_period_end_date`, `drug_era_end_date + grace`, a fixed offset such as +365 days, or the first outcome). Cohort exit is the operationalization of that end rule plus all competing reasons follow-up stops — death, disenrollment, the end of the database, switching, or the outcome itself.
TAR start, cohort exit, and the censoring source together fix the denominator (person-time) and which numerator (outcome) events are eligible to be counted. Get them wrong and you do not get a noisier estimate of the right thing — you get a precise estimate of the wrong thing.
Core conceptual distinction
Three decisions are separable and each maps onto a distinct estimand. (1) Where does the clock start? Anchoring TAR start at the exposure decision (time zero) is what prevents immortal time bias: any person-time accrued before the exposure-defining event is, by construction, event-free and must not be counted toward the exposed denominator. Anchoring at diagnosis or enrollment instead of initiation re-introduces it.
(2) Where does the clock stop, by design? An intention-to-treat-like (ITT) TAR runs from time zero to a fixed horizon or end of observation regardless of treatment changes; an on-treatment / as-treated TAR runs only while the exposure era persists (last `days_supply` end + a grace period), and censoring at discontinuation/switch is informative unless handled (IPCW). These estimate different quantities — the effect of starting a strategy vs the effect of staying on it.
(3) What is the censoring source, and is it a competing event? If death is treated as plain censoring for a non-fatal outcome you estimate the cause-specific quantity (rate among those still alive and at risk); if you want the absolute probability of the outcome in a population where people die of other causes, death is a competing risk and the cumulative incidence must be estimated with a Fine-Gray/Aalen-Johansen approach, not 1 − Kaplan-Meier. The TAR/exit specification is the choice of estimand; it cannot be deferred to the modeling stage.
Pros, cons, and trade-offs
- Explicit offset-based TAR (OMOP) vs an ad-hoc "from index to last claim" follow-up rule. Writing TAR as anchor + start/end offsets with a named censoring source makes time zero, immortal time, and the estimand auditable and portable across an OHDSI network; the cost is that every offset is a forced decision that must be defended and varied in sensitivity analysis. Prefer the explicit form for any regulatory- or HTA-grade study. The ad-hoc rule hides immortal time and is unreproducible across sites.
- ITT-like TAR vs on-treatment TAR. ITT is robust to informative censoring and answers the policy-relevant "start-the-drug" question, but dilutes effects under heavy switching/discontinuation; on-treatment isolates the biological on-drug effect but requires episode construction (days_supply stitching, grace period) and IPCW for the informative censoring it creates. Prefer ITT as the primary unless the question is explicitly about sustained use, then carry on-treatment as a pre-specified secondary. This is the same axis that separates `active-comparator-new-user` ITT contrasts from `clone-censor-weight-per-protocol` per-protocol estimands.
- Death as censoring vs death as competing risk. Censoring death is correct for the cause-specific hazard and for etiologic questions; it overstates absolute risk whenever mortality differs by arm (e.g., elderly comparative safety). Prefer the competing-risks cumulative incidence for any decision-analytic or absolute-risk output (see `competing-risks-cause-specific-fine-gray-rwe`).
When NOT to use — and when it is actively misleading or dangerous
- Do not anchor TAR start before the exposure-defining event. Starting follow-up at diagnosis or hospital admission but classifying exposure by a fill that occurs days later guarantees immortal time — the exposed group is credited with event-free survival they only achieved by living long enough to be exposed. This inflates apparent benefit and is the single most common self-inflicted injury in claims studies.
- Do not let TAR extend past observable person-time. If the end rule (e.g., +365 days) runs beyond `observation_period_end_date`, disenrollment, or the database cutoff, you fabricate event-free person-time and undercount outcomes. Cohort exit must be the minimum of the design end rule and every loss-of-observability date.
- Do not treat death as ordinary censoring for absolute-risk or HTA outputs when mortality is non-trivial and differs by arm — the resulting 1 − KM curve is not a real-world probability and can reverse a cost-effectiveness conclusion.
- Do not use an on-treatment TAR with naive censoring at discontinuation when discontinuation is prognostic; the informative censoring biases the on-treatment estimate. Use IPCW or fall back to ITT.
- Do not reuse one TAR across heterogeneous outcomes. An acute outcome (e.g., anaphylaxis, days) and a chronic one (e.g., incident HF, years) need different end rules and induction/latency offsets; a single window misclassifies one of them.
Data-source operational depth
- Claims (FFS): TAR start = index fill/procedure date; cohort exit = min(design end rule, disenrollment date, death date, end of data). Continuous medical + pharmacy enrollment must blanket the entire TAR — a gap is unobserved person-time, not event-free time. Adjudication lag and claim reversals mean an event near the data cutoff may not yet be in the extract; right-truncate the study end to allow run-out. Procedure-anchored studies are a classic immortal-time trap: time from admission to the procedure is immortal and must be excluded or assigned to the unexposed state.
- Claims mapped to OMOP / Medicare Advantage: When claims are ETL'd into OMOP, `observation_period` is built from enrollment spans — but MA-only person-time generally lacks adjudicated FFS claims, so outcomes and exposures are under-captured even though `observation_period` looks continuous. Restrict TAR to FFS-observable spans (Parts A/B/D), exclude MA-only periods, and never trust `observation_period_end_date` as a censoring source without confirming the underlying benefit type.
- EHR: Capture is encounter-driven, so absence of an outcome can be absence of a visit. A patient who leaves the health system is differentially and informatively lost; define `observation_period` from real contact density (e.g., ≥1 encounter per interval) rather than assuming continuity to the database cutoff, and prefer linked claims/death index to firm up cohort exit. Outcomes diagnosed at outside facilities (external-care leakage) are missed entirely.
- Registry / linked: Registries give adjudicated outcomes and severity but rarely complete exposure or full mortality; link to claims for fills and to a vital-records/death index so the competing-risk and censoring dates are real. Linkage selects the linkable subset (a transportability threat) and creates order/fill/service date discrepancies that must be reconciled before the TAR anchor is set.
- Differential competing risks in the elderly: In an aged claims cohort, the arm prescribed to frailer patients will have higher non-outcome mortality; censoring those deaths inflates that arm's cause-specific outcome rate. This is a data-source-driven artifact of the exit rule, not a treatment effect — diagnose it by tabulating competing-event incidence by arm before interpreting the primary result.
Worked claims example (two TAR variants, one estimand decision)
Question: incident hospitalized heart failure (HF) among new initiators of drug A vs drug B in a Medicare FFS + commercial OMOP instance. Cohort entry (`cohort_start_date`) = first fill of A or B with 365 days of prior continuous, FFS-observable enrollment and no prior fill of either drug (new-user washout). TAR start = `cohort_start_date + 1` (offset +1 excludes an HF claim coded on the index day, which is prevalent, not incident). Outcome = first inpatient HF claim in a validated position. Now the design fork:
Variant 1 — ITT-like. TAR end = min(`cohort_start_date + 730`, `observation_period_end_date`, `death_date`, study end). Person-time is counted regardless of whether the patient stays on drug; the estimand is the effect of initiating A vs B on 2-year HF risk. Death is modeled as a competing risk because these are elderly patients, so the reported quantity is the cumulative incidence of HF, with a parallel cause-specific hazard for the etiologic contrast.
Variant 2 — on-treatment. TAR end = min(last `days_supply` end + 90-day grace, switch to the other drug, `observation_period_end_date`, `death_date`, study end). This isolates the on-drug effect but the censoring at discontinuation is informative (patients who feel worse stop), so IPCW is applied.
Both variants use the identical censoring hierarchy and the same outcome definition; only the end rule differs, and that difference is exactly the difference in estimand. Sensitivity analyses vary the +1 induction offset, the grace period (60/90/120 days), the ITT horizon (1 vs 2 years), and a negative-control outcome to probe residual confounding. Reported diagnostics: pre/post TAR person-time by arm, distribution of cohort-exit reasons by arm, and competing-event (death) incidence by arm.
Decision diagram
timeline title One subject's OMOP time-at-risk and cohort exit Observation period start : enrollment / first contact (left edge of observable person-time) Cohort start (time zero) : first qualifying drug_exposure -> exposure decision TAR start : cohort_start_date + induction offset (e.g. +1 day) Person-time accrues : at-risk window, immortal time before time zero is excluded First outcome OR competing event : inpatient HF (outcome) or death (competing risk) Cohort exit : min(design end rule, observation_period_end, death, switch, data end)
flowchart TD
Q[What is the estimand?] --> A{Effect of starting<br/>or staying on treatment?}
A -->|Starting a strategy| ITT[ITT-like TAR:<br/>time zero -> fixed horizon<br/>or end of observation]
A -->|Staying on treatment| OnTx[On-treatment TAR:<br/>last days_supply end + grace;<br/>censor at discontinuation/switch]
OnTx --> IPCW{Is discontinuation<br/>prognostic?}
IPCW -->|Yes| W[Apply inverse-probability-of-<br/>censoring weighting]
IPCW -->|No| Keep[Naive censoring acceptable]
ITT --> D{Outcome non-fatal AND<br/>mortality differs by arm?}
W --> D
Keep --> D
D -->|Yes, need absolute risk| CR[Death = competing risk:<br/>cumulative incidence / Fine-Gray]
D -->|Etiologic hazard contrast| CS[Death = censoring:<br/>cause-specific hazard]
CR --> Floor[Floor every exit date at<br/>min observation-period end / death / data end]
CS --> FloorWorked example
Scenario
A Medicare claims study is asking whether a new blood-pressure drug (Drug A) causes a first hospitalization for heart failure. Patient 2201 fills Drug A for the first time on 2023-03-01 -- that is the index date. The study design says: open the watch window one day after the index date (to avoid counting heart failure that was already coded on the fill day), and follow each patient for up to 365 days. Patient 2201 loses insurance coverage on 2023-09-15; after that date her claims are invisible to the database. She never has a heart-failure hospitalization before that date. We want to know how many days she contributes to the study denominator and why her follow-up ends when it does.
Dataset
Key dates for patient 2201 pulled from OMOP-style tables -- the anchors an analyst joins before computing the time-at-risk window.
| person_id | event | date | note |
|---|---|---|---|
| 2201 | first Drug A fill (index date) | 2023-03-01 | cohort entry -- day zero |
| 2201 | insurance coverage ends | 2023-09-15 | observation period end date |
| 2201 | design end rule | 2024-02-29 | index date + 365 days |
| 2201 | heart-failure hospitalization | none | outcome never occurred |
Steps
Result
- Label
198 days at risk; cohort exit = end of observation period (insurance lapse on 2023-09-15)
- Value
198
Trade-offs
Runnable example
Derive per-person time-at-risk and cohort-exit dates from OMOP-style tables, for both an ITT-like and an on-treatment TAR. Required inputs (already ETL'd / de-duplicated; one row granularity noted): cohort : person_id, cohort_start_date (datetime) # time zero = first qualifying drug_exposure obs : person_id,...
import pandas as pd
INDUCTION_OFFSET = pd.Timedelta(days=1) # +1: exclude outcomes coded on the index day (prevalent, not incident)
ITT_HORIZON = pd.Timedelta(days=730) # fixed ITT follow-up horizon
GRACE = pd.Timedelta(days=90) # on-treatment grace after last days_supply end
def build_tar(cohort: pd.DataFrame, obs: pd.DataFrame,
death: pd.DataFrame, drug_era: pd.DataFrame) -> pd.DataFrame:
df = cohort.merge(obs, on="person_id", how="left") \
.merge(death, on="person_id", how="left")
# On-treatment era end = last drug_era_end_date for this person's index era + grace.
era_end = (drug_era.groupby("person_id")["drug_era_end_date"].max()
.rename("era_end").reset_index())
df = df.merge(era_end, on="person_id", how="left")
# TAR start: time zero + induction offset.
df["tar_start"] = df["cohort_start_date"] + INDUCTION_OFFSET
# Loss-of-observability fl: cohort exit can never exceed observed person-time.
obs_floor = df[["observation_period_end_date", "death_date"]].min(axis=1)
# ITT-like exit = min(fixed horizon, end of observability).
df["tar_end_itt"] = pd.concat(
[df["cohort_start_date"] + ITT_HORIZON, obs_floor], axis=1).min(axis=1)
# On-treatment exit = min(era end + grace, end of observability).
df["tar_end_ontx"] = pd.concat(
[df["era_end"] + GRACE, obs_floor], axis=1).min(axis=1)
# Drop persons with no positive person-time (exit on or before tar_start).
df = df[df["tar_end_itt"] > df["tar_start"]].copy()
return df[["person_id", "cohort_start_date", "tar_start",
"tar_end_itt", "tar_end_ontx", "death_date"]]OMOP time-at-risk and cohort-exit derivation with data.table; inputs mirror the Python version: cohort : person_id, cohort_start_date (Date) obs : person_id, observation_period_end_date (Date) death : person_id, death_date (Date, possibly NA) drug_era : person_id, drug_era_end_date (Date) Returns tar_start plus...
library(data.table)
INDUCTION <- 1L # +1 day induction offset (exclude index-day prevalent outcomes)
ITT_HORIZON <- 730L # fixed ITT horizon in days
GRACE <- 90L # on-treatment grace after last era end
build_tar <- function(cohort, obs, death, drug_era) {
setDT(cohort); setDT(obs); setDT(death); setDT(drug_era)
era_end <- drug_era[, .(era_end = max(drug_era_end_date)), by = person_id]
df <- merge(cohort, obs[, .(person_id, observation_period_end_date)], by = "person_id", all.x = TRUE)
df <- merge(df, death[, .(person_id, death_date)], by = "person_id", all.x = TRUE)
df <- merge(df, era_end, by = "person_id", all.x = TRUE)
df[, tar_start := cohort_start_date + INDUCTION]
# Cohort exit can never exceed observed person-time (min of obs-period end and death).
df[, obs_floor := pmin(observation_period_end_date, death_date, na.rm = TRUE)]
df[, tar_end_itt := pmin(cohort_start_date + ITT_HORIZON, obs_floor, na.rm = TRUE)]
df[, tar_end_ontx := pmin(era_end + GRACE, obs_floor, na.rm = TRUE)]
df <- df[tar_end_itt > tar_start] # require positive person-time
df[, .(person_id, cohort_start_date, tar_start, tar_end_itt, tar_end_ontx, death_date)]
}Time-at-risk and cohort-exit construction directly on OMOP CDM tables in SAS, producing ITT-like and on-treatment exit dates, then a person-time + incidence-rate summary with PROC GENMOD (Poisson).
%let induction = 1; /* +1 day: exclude index-day prevalent outcomes */
%let itt_horizon = 730; /* fixed ITT follow-up horizon (days) */
%let grace = 90; /* on-treatment grace after last era end */
/* Collapse to one last-era-end per person (on-treatment exit anchor). */
proc sql;
create table _era as
select person_id, max(drug_era_end_date) as era_end format=date9.
from omop.drug_era group by person_id;
quit;
/* Assemble anchors and derive both TAR variants with observability flooring. */
proc sql;
create table tar as
select c.subject_id as person_id,
c.cohort_start_date,
c.cohort_start_date + &induction as tar_start format=date9.,
min(op.observation_period_end_date, d.death_date) as obs_floor format=date9.,
min(c.cohort_start_date + &itt_horizon,
calculated obs_floor) as tar_end_itt format=date9.,
min(e.era_end + &grace,
calculated obs_floor) as tar_end_ontx format=date9.,
d.death_date
from omop.cohort c
left join omop.observation_period op on c.subject_id = op.person_id
left join omop.death d on c.subject_id = d.person_id
left join _era e on c.subject_id = e.person_id;
quit;
/* Eligible outcome = first outcome inside the chosen TAR; person-time in person-years (ITT shown). */
proc sql;
create table rate_in as
select t.person_id,
(t.tar_end_itt - t.tar_start) / 365.25 as pyears,
(max(o.outcome_date) is not missing
and min(o.outcome_date) between t.tar_start and t.tar_end_itt) as event
from tar t
left join omop.outcome o
on t.person_id = o.person_id
and o.outcome_date between t.tar_start and t.tar_end_itt
where t.tar_end_itt > t.tar_start
group by t.person_id, calculated pyears;
quit;
/* Incidence rate via Poisson with log person-time offset (the standard person-time model). */
proc genmod data=rate_in;
model event = / dist=poisson link=log offset=log_py;
output out=fitted p=ratehat;
run;Citations
- [1]Suissa S. Immortal time bias in pharmacoepidemiology. American Journal of Epidemiology. 2008;167(4):492-499.
- [2]Hernán MA, Sauer BC, Hernández-Díaz S, Platt R, Shrier I. Specifying a target trial prevents immortal time bias and other self-inflicted injuries in observational analyses. Journal of Clinical Epidemiology. 2016;79:70-75.
- [3]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.