Concepts and domains¶
If you read one chapter on this site, read this one. The concept ID is the load-bearing idea of the entire OMOP CDM, and almost every FHIR to OMOP mistake traces back to a misunderstanding here.
The concept¶
In OMOP, a concept is a clinical idea with a stable numeric identifier. Type 2 diabetes mellitus is concept 201826. Metformin is concept 1503297. Hemoglobin A1c measurement is concept 3004410.
These identifiers are the same everywhere. That is the point. A concept set built at one institution selects the same clinical idea at another, which is what makes a study portable.
The CONCEPT table has these columns, and each does real work:
| Column | What it tells you |
|---|---|
concept_id |
The stable identifier |
concept_name |
Human-readable name |
domain_id |
Which OMOP CDM table rows using this concept belong in |
vocabulary_id |
Which source vocabulary it came from: SNOMED, RxNorm, LOINC, ICD10CM |
concept_class_id |
The level of granularity within that vocabulary: Clinical Finding, Ingredient, Lab Test |
standard_concept |
S for standard, C for classification, null for neither |
concept_code |
The original code in its source vocabulary |
valid_start_date, valid_end_date, invalid_reason |
Lifecycle, because vocabularies change |
Two of those columns decide almost everything: domain_id and standard_concept.
The domain¶
Every concept carries a domain_id, and the domain determines which OMOP CDM table a row using that concept belongs in.
| Domain | Table |
|---|---|
Condition |
CONDITION_OCCURRENCE |
Drug |
DRUG_EXPOSURE |
Procedure |
PROCEDURE_OCCURRENCE |
Measurement |
MEASUREMENT |
Observation |
OBSERVATION |
Device |
DEVICE_EXPOSURE |
Visit |
VISIT_OCCURRENCE |
Specimen |
SPECIMEN |
Gender, Race, Ethnicity |
Columns in PERSON |
Unit |
unit_concept_id columns |
Type Concept |
_type_concept_id columns |
Relationship, Metadata |
Structural |
Read that table again with a specific realization in mind: the domain is a property of the concept, not of the source resource type.
This is the rule that reorganizes how you think about the transformation. You do not decide where a row goes by looking at whether it arrived as a FHIR Condition or a FHIR Observation. You look up the concept and read its domain.
The consequences are immediate and initially uncomfortable:
- A FHIR
Conditioncarrying a family history code goes toOBSERVATION - A FHIR
Observationcarrying a laboratory LOINC code goes toMEASUREMENT - A FHIR
Observationcarrying a smoking status code goes toOBSERVATION - A FHIR
Procedurecarrying a code that SNOMED classifies as a clinical finding may go toCONDITION_OCCURRENCE - A FHIR
Conditioncarrying a code for a device-related state may go toDEVICE_EXPOSURE
Newcomers argue with this rule for about two weeks. Then it becomes the thing that makes the model coherent, because it means the destination of a fact is determined by what the fact is rather than by which system happened to record it and in which resource.
Standard, classification, and non-standard¶
The standard_concept column has three states.
S, standard. This concept is the designated representation of its clinical idea. Standard concepts are what go in the _concept_id columns of clinical tables, and they are what concept sets and cohort definitions are built from.
C, classification. This concept is a grouper that exists to organize hierarchy but is not itself used to record events. ATC classes for drugs and MedDRA terms are typical. You can use classification concepts to select descendants through CONCEPT_ANCESTOR, but you do not write them into event rows.
Null, non-standard. This concept represents a code in a source vocabulary that OMOP does not standardize on. Almost all of ICD-10-CM is non-standard, because OMOP standardizes conditions on SNOMED CT. Non-standard is not a criticism; it means "this is what your source said, and here is what it corresponds to".
Non-standard concepts have a job. They go in _source_concept_id, preserving what you started with in a form that is queryable rather than just a text string.
The vocabularies OMOP standardizes on¶
By domain, the standard vocabulary is generally:
| Domain | Standard vocabulary | Common source vocabularies you will map from |
|---|---|---|
| Condition | SNOMED CT | ICD-10-CM, ICD-9-CM, ICD-10, Read |
| Drug | RxNorm and RxNorm Extension | NDC, local formulary codes, ATC as classification |
| Procedure | SNOMED CT, with CPT4, HCPCS, ICD-10-PCS also standard in the Procedure domain | Local procedure catalogs |
| Measurement | LOINC | Local laboratory codes, CPT4 for some tests |
| Observation | SNOMED CT and LOINC | Survey instruments, local codes |
| Device | SNOMED CT | Local device catalogs, some HCPCS |
| Unit | UCUM | Free-text unit strings |
The FHIR side helps here more than people expect. US Core already binds most of these fields to the same vocabularies OMOP wants. LOINC for laboratory results, RxNorm for medications, SNOMED and ICD-10-CM for conditions. That alignment is not coincidental; both communities converged on the same terminologies, which is a large part of why this transformation is tractable at all.
Maps to and how translation actually happens¶
The CONCEPT_RELATIONSHIP table holds the relationships between concepts. The one that does the work is Maps to.
To translate a source code:
- Find the concept for your source code by
vocabulary_idandconcept_code - If it is already standard, use it
- If it is not, follow its
Maps torelationship inCONCEPT_RELATIONSHIP - The target of that relationship is the standard concept
- Write the standard concept to
_concept_idand the original to_source_concept_id
Worked through R. Alvarez's diabetes diagnosis:
Source: ICD-10-CM E11.9
Step 1 CONCEPT where vocabulary_id='ICD10CM' and concept_code='E11.9'
→ concept_id 35208414, standard_concept = NULL, domain_id = 'Condition'
Step 2 Not standard, so follow the relationship
Step 3 CONCEPT_RELATIONSHIP where concept_id_1 = 35208414
and relationship_id = 'Maps to'
→ concept_id_2 = 201826
Step 4 CONCEPT where concept_id = 201826
→ 'Type 2 diabetes mellitus', SNOMED, standard_concept = 'S',
domain_id = 'Condition'
Result condition_concept_id = 201826
condition_source_concept_id = 35208414
condition_source_value = 'E11.9'
Destination: CONDITION_OCCURRENCE, because domain is Condition
Three complications that appear in real data:
One source code can map to several standard concepts. Some ICD-10-CM codes bundle ideas that SNOMED separates. The convention is to write a row for each target, which means one source record can legitimately become multiple OMOP CDM rows. Pipelines that assume a one-to-one mapping quietly drop the extras.
The mapping target can be in a different domain than the source concept. This is exactly the family history case. The source concept's domain and the target's domain can differ, and the target's domain is what decides the table. Route on the standard concept, always.
Some codes map to nothing. No Maps to relationship exists. Then _concept_id gets 0 and you make a decision about whether to build a local mapping.
Concept ancestry¶
CONCEPT_ANCESTOR is a precomputed transitive closure of the hierarchy. It lets you ask for a concept and all of its descendants in one join, which is how a cohort definition for "any type 2 diabetes" picks up the several hundred more specific concepts underneath it.
You do not populate this table and you rarely query it during ETL. It affects you for one reason: if you map a source code to an overly specific concept, it still gets picked up by broader concept sets through ancestry, but if you map it to an overly broad one, specific concept sets will miss it. When a mapping is genuinely ambiguous, the less specific choice loses more information than it appears to.
Where this quietly breaks¶
Routing on source concept domain instead of standard concept domain. The classic bug. It puts family history codes into CONDITION_OCCURRENCE and creates cohorts full of people who do not have the disease. Route on the target.
Treating concept_id = 0 as a null. Zero is a value. Count it, report it, monitor it. A rising zero rate is the earliest signal that your vocabulary version has drifted from your source data.
Vocabulary version drift. Concepts get deprecated and remapped between vocabulary releases. An instance loaded against a 2023 vocabulary and analyzed against a 2026 one will have inconsistencies. Record your vocabulary version in CDM_SOURCE and treat a vocabulary upgrade as a re-mapping event rather than a routine update.
Assuming display text is authoritative. The display in a FHIR Coding is a convenience string and is often locally customized or stale. Map on system plus code. Where they disagree, the code wins.
Ignoring invalid_reason. Concepts have lifecycles. A concept with a non-null invalid_reason has been deprecated, and there is usually a Concept replaced by relationship pointing at its successor. Loading deprecated concepts produces data that current tools will not find.
Next¶
- Source and standard, on the column pairs and why both are kept
- Domain routing, the rule in practice with a decision procedure