OMOP CONDITION_OCCURRENCE and CONDITION_ERA
The two OMOP CDM tables that hold diagnoses (CONDITION_OCCURRENCE, one row per recorded condition event) and their derived persistence-window collapse (CONDITION_ERA), used to operationalize disease onset, recurrence, and chronic-disease history into cohort entry, comorbidity, and outcome variables.
In plain language
In a standardized health database built on the OMOP model, every time a patient receives a diagnosis — from a doctor visit, a hospital stay, or a problem list entry — that event lands as one row in a table called CONDITION_OCCURRENCE. Because the same chronic disease generates many separate visits over months or years, the database also builds a second table called CONDITION_ERA, which stitches those scattered diagnosis events into a single continuous time span whenever the gap between consecutive events is no wider than 30 days. Use CONDITION_OCCURRENCE when you want to find the first time a disease was recorded or count specific events; use CONDITION_ERA when you want to ask whether a patient had a disease present during a particular period.
In the OMOP Common Data Model (CDM), almost every diagnosis-based variable in a real-world study is built from two tables. CONDITION_OCCURRENCE stores one row per recorded condition event: `person_id`, a standard `condition_concept_id` (SNOMED), `condition_start_date`/`condition_end_date`, the source code (`condition_source_value`), the `condition_type_concept_id` that records how the diagnosis was captured (inpatient primary, inpatient secondary, outpatient/professional claim, EHR problem list, EHR encounter diagnosis), and `condition_status_concept_id` (admitting, final, rule-out). CONDITION_ERA is a derived table: OHDSI's standard ETL collapses consecutive CONDITION_OCCURRENCE rows of the same condition into a single span (`condition_era_start_date`, `condition_era_end_date`, `condition_occurrence_count`) whenever the gap between events is no greater than a persistence window (OHDSI default 30 days). The occurrence table is the raw evidence; the era table is one opinionated summary of it.
Core conceptual distinction
. The two tables answer different questions and fail in different ways. CONDITION_OCCURRENCE is the unit for event questions — first-ever diagnosis (incident outcome), date of an acute event, count of encounters carrying a code — and it preserves the provenance you need to filter (an inpatient primary MI is not a rule-out MI coded on a chest-pain visit). CONDITION_ERA is the unit for state questions — "did this person have chronic condition X during the baseline window?" — because it has already stitched fragmented encounters into a presence/absence span. The catch is that the era is only as good as the persistence window: with a 30-day gap, two heart-failure visits 45 days apart are two eras (looks intermittent), while a 180-day gap makes them one continuous era (looks chronic). The window is an analytic choice that silently rewrites prevalence and "chronic" status, so the estimand-relevant decision — what counts as having the condition, and over what calendar window — must be pre-specified and sensitivity-tested, not inherited from the ETL default. Neither table validates a phenotype; both are the substrate on top of which a condition era / occurrence-based algorithm (e.g., 1 inpatient OR 2 outpatient codes ≥30 days apart) is layered.
Pros, cons, and trade-offs
. - CONDITION_ERA vs raw CONDITION_OCCURRENCE for chronic-disease ascertainment: the era table gives you a ready-made, de-duplicated presence span and avoids hand-rolling gap logic; it is the right primitive for comorbidity flags and "prevalent disease at index." Cost: it bakes in the 30-day gap and discards `condition_type_concept_id`, so you can no longer require inpatient-primary position or exclude rule-out codes from inside the era. Prefer the era for chronic, well-coded conditions where any contact implies presence; prefer raw occurrence whenever code position, diagnosis status, or a custom 2-codes-in-N-days rule matters (most validated phenotypes). - vs a bespoke phenotype/cohort algorithm (ATLAS/Capr cohort, or the 1ip-2op rule): the eras/occurrences are inputs to those algorithms, not substitutes. A naive `≥1 condition_concept_id` flag over CONDITION_OCCURRENCE is fast but has poor PPV for most outcomes; a validated algorithm trades sensitivity for specificity. Prefer the validated algorithm for any analytic outcome or eligibility criterion; reserve the raw-flag-from-era shortcut for descriptive comorbidity adjustment where misclassification is non-differential and tolerable. - vs source-vocabulary (ICD-9/10) logic in native claims: working in standardized `condition_concept_id` makes code lists portable across data sources and networks (the OHDSI advantage). Cost: mapping from source codes to SNOMED is lossy and ETL-dependent; a concept that is split or deprecated upstream can change cohort counts between CDM vintages. Prefer standardized concepts for multi-database/network studies; audit the source-to-standard mapping (and keep `condition_source_value`) when a single high-stakes algorithm must match a published ICD-based definition exactly.
When to use
. Use CONDITION_OCCURRENCE to define incident/recurrent disease events, index dates, acute outcomes, and any algorithm that conditions on diagnosis position or status. Use CONDITION_ERA to define chronic-disease history, prevalent comorbidities at baseline, and presence/absence covariates where the persistence-collapsed span is the natural unit. Use both together in the canonical pattern: era-based comorbidities for the baseline covariate block, occurrence-based first-event logic for the outcome.
When NOT to use — and when it is actively misleading or dangerous
. - Do not use CONDITION_ERA when code position or rule-out status is part of the definition. The era has already discarded `condition_type_concept_id`/`condition_status_concept_id`, so a "rule-out cancer" code on an imaging visit gets folded into a "cancer" era. For outcomes like cancer, MI, or stroke this inflates incidence and biases comparative estimates if coding intensity differs by arm. - Do not treat the 30-day default as physiology. Reporting "chronic disease" prevalence off the default gap without a sensitivity analysis (30/60/180 days) is indefensible — the number is an artifact of the ETL knob. - Do not read the first CONDITION_OCCURRENCE row as disease onset without an adequate clean/washout window inside an observed OBSERVATION_PERIOD. The first recorded date is when the patient entered the data with the code, not when disease began (left censoring / prevalent-as-incident misclassification). - Do not pool diagnosis events across data sources with different `condition_type_concept_id` mixes. A database that is mostly outpatient EHR problem-list entries and one that is mostly inpatient claims will produce non-comparable era structures even for the same persistence window. - Do not assume CONDITION_ERA exists or is current. It is a derived table; some CDM instances skip it, build it with a non-default gap, or fail to rebuild it after an incremental load. Verify the ETL spec and the era-build date before relying on it.
Data-source operational depth
. - Administrative claims: `condition_type_concept_id` distinguishes inpatient principal/secondary from professional/ outpatient claims — this is the backbone of validated 1-inpatient-OR-2-outpatient rules. Failure modes: rule-out coding (a diagnosis placed on a lab/imaging encounter to justify the test, not to assert disease) inflates occurrence counts and, once collapsed, contaminates eras; require diagnosis position and/or a second confirmatory code. Claims adjudication lag means recent person-time looks disease-free; freeze data with a run-out buffer. Medicare Advantage-only person-time lacks fee-for-service claims, so absence of a CONDITION_OCCURRENCE row is missingness, not true absence — restrict to A/B/D FFS person-time when ascertaining baseline conditions or incident outcomes. - EHR: the same condition can appear as a problem-list entry (carried forward indefinitely, no natural end date), an encounter diagnosis, or a resolved condition; `condition_status_concept_id` that would disambiguate them is frequently null. Problem-list carry-forward makes CONDITION_ERA end dates unreliable and can make a resolved condition look chronic. Visit-driven capture means a patient who gets care outside the system has phantom "gaps" that fragment eras; reconcile with the observed OBSERVATION_PERIOD before interpreting era discontinuity. - Registry: strongest for adjudicated, well-defined conditions (e.g., cancer with stage) but typically narrow in scope and weak for incidental comorbidities; map registry diagnoses into CONDITION_OCCURRENCE carefully and do not expect a complete comorbidity profile from registry-derived eras alone. - Linked claims–EHR(–registry): the richest substrate, but the same condition is now captured by multiple feeds with different dates and positions; de-duplicate before the era ETL or eras double-count, and reconcile differential capture so that exposure arms are not compared on systematically different coding density.
Worked claims/CDM example — chronic HF comorbidity + incident HF outcome
Question: among new initiators of drug A vs drug B (index date = first qualifying fill), (i) flag baseline chronic heart failure as a confounder and (ii) ascertain incident HF as the outcome, in an OMOP-mapped commercial + Medicare FFS claims database. Define the HF concept set as a standard `condition_concept_id` descendant list. Baseline comorbidity (state question → CONDITION_ERA): a person has baseline HF if they have ≥1 HF CONDITION_ERA overlapping the 365-day pre-index window. Because eras drop diagnosis position, pre-specify and report the sensitivity to the persistence gap (rebuild eras or apply a custom gap of 30/60/180 days) and cross-check against the position-aware rule below. Incident outcome (event question → CONDITION_OCCURRENCE with a validated algorithm): a qualifying HF event = either one HF code with `condition_type_concept_id` = inpatient principal, OR two outpatient/professional HF codes ≥30 days apart; the outcome date is the first such qualifying date after index. Apply a clean window — no HF CONDITION_OCCURRENCE row in the 365 days before index — so first-recorded is plausibly incident, and require the entire baseline and at-risk period to fall inside a continuous OBSERVATION_PERIOD on FFS (A/B/D) coverage, excluding MA-only person-time where claims are absent. Censor at observation-period end, death, or end of data. Sensitivity analyses: era persistence gap (30/60/180), the 1-inpatient-vs-2-outpatient rule, and a negative-control condition to detect differential coding intensity between arms.
Interpreting the output
. Patient 2001 has three heart failure CONDITION_OCCURRENCE rows: February 1, February 29 (gap = 28 days from the February 1 discharge), and April 14 (gap = 45 days from the February 29 discharge). With a 30-day persistence window, the February 1 and February 29 occurrences are within the window and collapse into a single CONDITION_ERA spanning February 1 through February 29; the April 14 occurrence exceeds the 30-day gap and opens a second, distinct CONDITION_ERA on April 14.
Formal interpretation: the same three diagnosis codes yield two CONDITION_ERAs under a 30-day persistence window but would yield one era under a 45-day window and three separate eras under a 7-day window. The era count is a construction artifact, not a clinical truth — the persistence window encodes an assumption about what gap between coded encounters represents a new disease episode versus continued care for the same episode. This construction choice propagates to any analysis that uses CONDITION_ERA to define prevalent disease exposure, disease duration, or episode count. Analysts who use the OMOP default 30-day window without checking whether it is appropriate for the target condition and coding environment are importing an assumption without examination.
Practical interpretation: always report the persistence gap used and run a sensitivity analysis at alternative gaps (30, 60, 180 days) for chronic conditions where the clinically meaningful episode boundary is ambiguous. For acute conditions — where any recurrence after full recovery is a distinct event — prefer the CONDITION_OCCURRENCE-based incident-event algorithm over condition era, and document that choice in the SAP.
Worked example
Scenario
Patient 2001 is enrolled in a commercial claims database with continuous coverage from 2024-01-01 through 2024-06-30. She has three outpatient encounters where a heart-failure diagnosis code is recorded: February 1, February 29, and April 14. We want to show exactly how the OMOP ETL collapses these three CONDITION_OCCURRENCE rows into CONDITION_ERA records using the default 30-day persistence window.
Dataset
Raw CONDITION_OCCURRENCE rows for patient 2001 (heart failure, condition_concept_id 316139)
| person_id | condition_concept_id | condition_start_date | condition_type_concept_id |
|---|---|---|---|
| 2001 | 316139 | 2024-02-01 | outpatient claim |
| 2001 | 316139 | 2024-02-29 | outpatient claim |
| 2001 | 316139 | 2024-04-14 | outpatient claim |
Steps
Sort the three occurrences by date: Feb 01, Feb 29, Apr 14.
Check the gap between occurrence A (Feb 01) and occurrence B (Feb 29): 28 days. Because 28 is less than or equal to the 30-day persistence window, A and B are bridged into the same era.
The era built from A and B spans Feb 01 through Feb 29 and has an occurrence count of 2.
Check the gap between occurrence B (Feb 29) and occurrence C (Apr 14): 45 days. Because 45 exceeds the 30-day persistence window, C starts a new era.
Era 2 spans Apr 14 through Apr 14 and has an occurrence count of 1.
Final result: 3 CONDITION_OCCURRENCE rows produce 2 CONDITION_ERA rows.
Result
3 CONDITION_OCCURRENCE events collapse into 2 CONDITION_ERAs: Era 1 covers 2024-02-01 to 2024-02-29 (28-day span, 2 occurrences); Era 2 covers 2024-04-14 to 2024-04-14 (1-day span, 1 occurrence). The 45-day gap between Feb 29 and Apr 14 exceeds the 30-day window and breaks continuity.
Timeline Spec
- Title
Three HF diagnosis claims collapsed into two CONDITION_ERAs (30-day persistence window)
- Window
- Start
2024-01-01
- End
2024-06-30
- Label
Observation window: 2024-01-01 to 2024-06-30
- Events
- Label
Occurrence A
- Start
2024-02-01
- Length Days
1
- Quantity
outpatient HF code
- Label
Occurrence B (28 days later)
- Start
2024-02-29
- Length Days
1
- Quantity
outpatient HF code
- Label
Occurrence C (45 days later — gap breaks era)
- Start
2024-04-14
- Length Days
1
- Quantity
outpatient HF code
- Spans
- Kind
covered
- Start
2024-02-01
- End
2024-02-29
- Label
Era 1: 28-day span, 2 occurrences (gap 28d <= 30d)
- Kind
gap
- Start
2024-03-01
- End
2024-04-13
- Label
45-day gap exceeds 30d window — era break
- Kind
covered
- Start
2024-04-14
- End
2024-04-14
- Label
Era 2: 1-day span, 1 occurrence
- Result
- Label
3 occurrences -> 2 eras (30-day persistence window splits at the 45-day gap)
- Value
2
Caption
Three outpatient heart-failure claims for patient 2001. The 28-day gap between Feb 01 and Feb 29 falls within the 30-day persistence window, so those two occurrences merge into Era 1. The 45-day gap between Feb 29 and Apr 14 exceeds the window, so Apr 14 becomes a separate Era 2.
Alt Text
Timeline from January through June 2024 showing three diagnosis-event markers on Feb 01, Feb 29, and Apr 14, with a green span labeled Era 1 bridging Feb 01 to Feb 29, a gray gap band from Mar 01 to Apr 13 labeled 45-day gap, and a second green span labeled Era 2 on Apr 14.
Runnable example
python implementation
Baseline era-presence flag + occurrence-based incident outcome from OMOP CDM tables (pandas). Required inputs (already loaded, OMOP-shaped, one schema per database): condition_occurrence : person_id, condition_concept_id, condition_start_date (datetime),...
import pandas as pd
HF_CONCEPTS = set(hf_concept_descendants) # standard condition_concept_id list for heart failure
INPATIENT_PRINCIPAL = {38000183, 38000199} # condition_type_concept_id values for inpatient primary dx
BASELINE_DAYS = 365
OUTPATIENT_GAP_DAYS = 30 # 2-outpatient-codes-apart requirement
def add_baseline_hf(cohort: pd.DataFrame, condition_era: pd.DataFrame) -> pd.DataFrame:
# STATE question -> CONDITION_ERA: any HF era overlapping [index-365, index].
era = condition_era[condition_era["condition_concept_id"].isin(HF_CONCEPTS)]
m = era.merge(cohort[["person_id", "index_date"]], on="person_id")
win_start = m["index_date"] - pd.Timedelta(days=BASELINE_DAYS)
overlap = (m["condition_era_start_date"] <= m["index_date"]) & (m["condition_era_end_date"] >= win_start)
flagged = m.loc[overlap, "person_id"].unique()
out = cohort.copy()
out["baseline_hf"] = out["person_id"].isin(flagged).astype(int)
return out
def add_incident_hf(cohort: pd.DataFrame, condition_occurrence: pd.DataFrame) -> pd.DataFrame:
# EVENT question -> CONDITION_OCCURRENCE with a validated rule, first qualifying date AFTER index.
co = condition_occurrence[condition_occurrence["condition_concept_id"].isin(HF_CONCEPTS)]
co = co.merge(cohort[["person_id", "index_date"]], on="person_id")
# Clean window: incident only if no HF occurrence in the 365 days before index.
pre = co[(co["condition_start_date"] < co["index_date"]) &
(co["condition_start_date"] >= co["index_date"] - pd.Timedelta(days=BASELINE_DAYS))]
prevalent = set(pre["person_id"])
post = co[(co["condition_start_date"] > co["index_date"]) & (~co["person_id"].isin(prevalent))].copy()
# Rule A: one inpatient-principal code.
ip = post[post["condition_type_concept_id"].isin(INPATIENT_PRINCIPAL)]
ip_date = ip.groupby("person_id")["condition_start_date"].min()
# Rule B: two outpatient codes >= OUTPATIENT_GAP_DAYS apart; qualifying date = the second code.
op = post[~post["condition_type_concept_id"].isin(INPATIENT_PRINCIPAL)].sort_values(
["person_id", "condition_start_date"])
op["prev_dt"] = op.groupby("person_id")["condition_start_date"].shift(1)
op_qual = op[(op["condition_start_date"] - op["prev_dt"]).dt.days >= OUTPATIENT_GAP_DAYS]
op_date = op_qual.groupby("person_id")["condition_start_date"].min()
first = pd.concat([ip_date, op_date], axis=1).min(axis=1).rename("incident_hf_date")
return cohort.merge(first, on="person_id", how="left")r implementation
Same two operations in R (dplyr). Inputs mirror the Python version and use OMOP column names: condition_occurrence : person_id, condition_concept_id, condition_start_date (Date), condition_type_concept_id condition_era : person_id, condition_concept_id,...
library(dplyr)
baseline_days <- 365L
outpatient_gap_days <- 30L
add_baseline_hf <- function(cohort, condition_era, hf_concepts) {
flagged <- condition_era %>%
filter(condition_concept_id %in% hf_concepts) %>%
inner_join(select(cohort, person_id, index_date), by = "person_id") %>%
filter(condition_era_start_date <= index_date,
condition_era_end_date >= index_date - baseline_days) %>%
distinct(person_id) %>% pull(person_id)
cohort %>% mutate(baseline_hf = as.integer(person_id %in% flagged))
}
add_incident_hf <- function(cohort, condition_occurrence, hf_concepts, inpatient_principal) {
co <- condition_occurrence %>%
filter(condition_concept_id %in% hf_concepts) %>%
inner_join(select(cohort, person_id, index_date), by = "person_id")
# Clean window: drop people with any HF code in the 365 days before index (prevalent).
prevalent <- co %>%
filter(condition_start_date < index_date,
condition_start_date >= index_date - baseline_days) %>%
distinct(person_id) %>% pull(person_id)
post <- co %>% filter(condition_start_date > index_date, !person_id %in% prevalent)
ip_date <- post %>% filter(condition_type_concept_id %in% inpatient_principal) %>%
group_by(person_id) %>% summarise(d = min(condition_start_date), .groups = "drop")
op_date <- post %>% filter(!condition_type_concept_id %in% inpatient_principal) %>%
arrange(person_id, condition_start_date) %>% group_by(person_id) %>%
mutate(prev_dt = lag(condition_start_date)) %>%
filter(as.integer(condition_start_date - prev_dt) >= outpatient_gap_days) %>%
summarise(d = min(condition_start_date), .groups = "drop")
first <- bind_rows(ip_date, op_date) %>% group_by(person_id) %>%
summarise(incident_hf_date = min(d), .groups = "drop")
cohort %>% left_join(first, by = "person_id")
}