Skip to content

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 Condition carrying a family history code goes to OBSERVATION
  • A FHIR Observation carrying a laboratory LOINC code goes to MEASUREMENT
  • A FHIR Observation carrying a smoking status code goes to OBSERVATION
  • A FHIR Procedure carrying a code that SNOMED classifies as a clinical finding may go to CONDITION_OCCURRENCE
  • A FHIR Condition carrying a code for a device-related state may go to DEVICE_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:

  1. Find the concept for your source code by vocabulary_id and concept_code
  2. If it is already standard, use it
  3. If it is not, follow its Maps to relationship in CONCEPT_RELATIONSHIP
  4. The target of that relationship is the standard concept
  5. Write the standard concept to _concept_id and 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