← Methods repository
concept

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.

Outcome_Measureoutcome_measureomop-cdmcondition-occurrencecondition-erapersistence-windowphenotypecomorbidity-ascertainmentdiagnosis-coding
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

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_idcondition_concept_idcondition_start_datecondition_type_concept_id
20013161392024-02-01outpatient claim
20013161392024-02-29outpatient claim
20013161392024-04-14outpatient 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")
}