← Methods repository
concept

SNOMED CT Clinical Terminology

A comprehensive, polyhierarchical clinical terminology — not a billing classification — that assigns numeric concept identifiers to clinical findings, disorders, procedures, and observable entities, enabling granular EHR phenotyping via descendant-hierarchy expansion and serving as the standard Condition vocabulary in the OMOP Common Data Model.

Data_Standardcoding-systemdata-standardprimitiveterminologyehrsnomedsnomed-ctomop
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

SNOMED CT is the world's largest clinical terminology — a giant dictionary that gives each clinical concept (a disease, a finding, a procedure) its own permanent numeric ID and connects those concepts in a hierarchy, so that searching for 'diabetes mellitus' automatically includes all the specific subtypes below it. Unlike ICD codes, which are designed for insurance billing and lump many things together, SNOMED CT is designed for clinical documentation and can describe what a clinician actually saw or decided with high precision. It is most commonly encountered in electronic health record (EHR) problem lists and is the vocabulary that the OMOP data model uses to store diagnoses — so understanding SNOMED CT is a prerequisite for understanding OMOP concept sets.

SNOMED CT

(Systematized Nomenclature of Medicine — Clinical Terms) is the world's most comprehensive clinical terminology and the vocabulary of record for capturing clinical meaning at the point of care. It is maintained by SNOMED International (a not-for-profit association of national member bodies) and distributed in the United States by the National Library of Medicine (NLM) through the US Edition. Use requires a license; in member countries, including the United States, that license is free — US users obtain access through the NLM's UMLS Metathesaurus license, which covers SNOMED CT for US purposes. Small illustrative examples with attribution to SNOMED International are acceptable; bulk reproduction of SNOMED CT content is not.

Core conceptual distinction: terminology vs classification

The single most important thing to understand about SNOMED CT is what it is not: it is not a billing classification. ICD-10-CM (and its predecessors) was designed for statistical, administrative, and reimbursement purposes — its categories are mutually exclusive, its hierarchy is coarse, and a single code often collapses many clinically distinct entities for the practical convenience of counting and billing. SNOMED CT was designed for the opposite purpose: to capture the full clinical meaning of what a clinician observed, recorded, or decided, with enough granularity and structure to support clinical decision support, research phenotyping, and inter-system exchange. This distinction is the conceptual spine of every RWE use case: ICD-10-CM tells you what was billed; SNOMED CT tells you (or tries to tell you) what actually happened clinically.

Three core components

Every concept in SNOMED CT has three mandatory elements. First, a concept — a unique, atomic clinical idea identified by a numeric concept identifier (SCTID) that is 6–18 digits long and encodes a partition (the type of component) and a Verhoeff check digit. The concept identifier is opaque and permanent: 44054006 represents |Diabetes mellitus type 2| in every edition, every country, every year it has existed. Second, descriptions — one or more human-readable labels for the concept, split into a single Fully Specified Name (FSN, which is globally unique and unambiguous), one Preferred Term (the display label for a locale), and zero or more Synonyms and Acceptable Terms. A single concept may have many acceptable terms; 73211009 has both |Diabetes mellitus| and |DM| as accepted synonyms. Third, relationships — formal machine-readable links between concepts, stored in the RF2 Relationship table. The most important relationship type is |Is a| (SCTID 116680003), which builds the hierarchy.

The |is a| polyhierarchy

Unlike ICD-10-CM's tree — where each code has exactly one parent — SNOMED CT is a directed acyclic graph (DAG). A concept can have multiple parents, making it polyhierarchical. For example, 44054006 |Diabetes mellitus type 2| is a child of both 73211009 |Diabetes mellitus| (the metabolic disorder lineage) and of 8801005 |Secondary diabetes mellitus| if the concept is inherited from multiple classification facets. This means descendant queries must traverse a graph, not a tree, typically via a transitive closure of |is a| relationships over the RF2 Relationship file — every concept that can reach a root concept through a chain of |is a| edges is a descendant. The practical upshot for RWE is that a concept-set built by "all descendants of 73211009 |Diabetes mellitus|" will capture far more granular codes than any flat ICD list, but the membership of that concept-set changes between SNOMED CT releases (twice yearly for the US Edition) as new concepts are added and retired.

Defining attribute relationships

Beyond |is a|, SNOMED CT uses additional relationship types — called defining attributes — to formally specify what makes a concept what it is. Clinical findings carry attributes such as |finding site| (which body structure is affected) and |associated morphology| (what is structurally abnormal). Disorders carry |causative agent|, |pathological process|, and others. These attributes power description logic classifiers (such as EL++ reasoners) that can infer implied subsumption relationships and detect modeling errors. For most RWE users, attribute relationships are background machinery; for advanced use cases (clinical decision support, automated phenotype generation), they are how SNOMED CT earns its designation as a "formal ontology" rather than a flat vocabulary.

Pre-coordination vs post-coordination

SNOMED CT concepts are either pre-coordinated — where a single SCTID expresses a complete clinical idea (e.g., 44054006 |Diabetes mellitus type 2|) — or post-coordinated, where a base concept is combined with additional expressions using SNOMED's Compositional Grammar to capture a nuanced clinical statement not represented by any single concept (e.g., a finding site applied to a generic finding concept at the point of documentation). Post-coordination is theoretically powerful but is rarely supported at the EHR interface level; most RWE work operates exclusively on pre-coordinated concepts from problem lists and encounter diagnoses.

Pros, cons, and trade-offs — specific and comparative

  • vs ICD-10-CM (the dominant alternative for condition coding in US claims): SNOMED CT's polyhierarchy
  • vs flat proprietary code lists (e.g., hand-curated ICD code lists for a specific indication): A
  • vs OMOP Concept Sets (which use SNOMED under the hood): OMOP's CONDITION_OCCURRENCE table stores
  • vs LOINC (laboratory/observation coding) and RxNorm (drug coding): SNOMED CT, LOINC, and RxNorm

When to use

Use SNOMED CT when building EHR-based phenotypes where clinical granularity matters and descendant hierarchy expansion adds meaningful analytic value — rare-disease research (where SNOMED descendants may outnumber ICD codes five-to-one), phenotypic subtypes relevant to the research question (distinguishing type 1 from type 2 diabetes), and multi-database distributed studies over OMOP where the standard vocabulary must be portable across sites with different source coding. Use SNOMED CT as the backbone of OMOP concept-set development for the Condition domain. Use it when your study requires synonymy-aware search (matching many terms to one concept) or when clinical decision support and cohort discovery live in the same system.

When NOT to use — and when it is actively misleading or dangerous

  • Do not use SNOMED CT as a drop-in replacement for ICD-10-CM in US claims analysis. US insurance
  • Do not ignore the terminology version. SNOMED CT releases in the United States occur twice yearly
  • Do not conflate problem-list SNOMED entries with encounter diagnoses. In EHR systems that use
  • Do not assume the SNOMED→ICD-10-CM map is lossless. SNOMED International publishes a rule-based
  • Do not use SNOMED CT for medication coding. SNOMED CT includes a Substance hierarchy and some

Data-source operational depth

  • EHR: SNOMED CT is most commonly present in EHR data as the vocabulary behind problem lists (the
  • Registry: Disease registries frequently use SNOMED CT for condition coding, often with tighter
  • Linked EHR-claims: The ideal configuration for SNOMED-based RWE — EHR provides SNOMED-coded
  • OMOP CDM: In OMOP, the CONDITION_OCCURRENCE table stores SNOMED CT concept IDs as the standard

Licensing note

SNOMED CT is owned by SNOMED International. Use requires a license. In member countries — which include the United States — access is free for most users. US users should obtain access through the NLM via a UMLS license (https://www.nlm.nih.gov/healthit/snomedct/index.html). Do not bulk-reproduce SNOMED CT content; small illustrative examples with attribution to SNOMED International are acceptable.

Worked example

Scenario

A pharmacoepidemiologist wants to build a type 2 diabetes cohort from an EHR dataset that has been converted to the OMOP CDM. She needs to understand why a SNOMED CT-based descendant expansion finds more patients than a flat ICD-10-CM code list, and whether the extra patients represent true type 2 diabetes or noise from the broader hierarchy. The example walks through the two approaches on a hypothetical patient database of 100,000 adults, shows the code counts each strategy produces, and computes the incremental capture rate.

Dataset

Comparison of two phenotyping strategies for type 2 diabetes: flat ICD-10-CM list vs SNOMED CT descendant expansion. Row counts are from a hypothetical 100,000-patient EHR-OMOP dataset.

StrategyRoot or seed conceptNumber of codes / concepts in setPatients identifiedNotes
Flat ICD-10-CM listE11 (Type 2 diabetes mellitus) and E11.* subcodes378200Manual enumeration of E11 and its subcategories; must be updated when new ICD codes are added
SNOMED CT descendant expansion44054006 (Diabetes mellitus type 2) and all is-a descendants1128960Automated hierarchy traversal in CONCEPT_ANCESTOR; includes granular clinical subtypes not represented in ICD-10-CM
Incremental patients (SNOMED only)Concepts without ICD-10-CM equivalent or mapping gap75760Patients coded with SNOMED-specific subtypes (e.g., maturity onset diabetes of the young in problem list) that did not have a corresponding E11 claim in the observation window

Steps

  • The ICD-10-CM strategy starts with E11 and lists all 37 codes in the E11 family (E11.0 through E11.9 and their 4th/5th digit extensions). Each is looked up in the OMOP CONCEPT table as a source code and then followed via the Maps-to relationship to its SNOMED standard concept. Patients with any CONDITION_OCCURRENCE record carrying one of those SNOMED standard concepts are flagged as cases.

  • The SNOMED descendant strategy starts directly with SNOMED concept 44054006 (Diabetes mellitus type 2) and queries the OMOP CONCEPT_ANCESTOR table for all concept_id values where ancestor_concept_id = 201826 (the OMOP standard concept ID for this SNOMED concept) and min_levels_of_separation >= 0, returning 112 descendant concepts across all levels of the hierarchy.

  • The SNOMED strategy identifies 8960 patients vs 8200 from the flat ICD list, a difference of 760 patients. That is 760 / 8200 = 0.0927, meaning about 9.3% more patients are captured by descendant expansion.

  • On inspection, the 760 extra patients were coded using SNOMED-specific granular subtypes on their EHR problem lists (e.g., maturity onset diabetes of the young type 3, gestational diabetes that evolved to type 2) that either had no direct ICD-10-CM equivalent or whose ICD codes were not present in the claims-derived CONDITION_OCCURRENCE records within the study window.

  • Key validation check -- of the 760 incremental patients, chart review of a 50-patient random sample confirms 82% are genuine type 2 diabetes cases documented by clinicians using SNOMED problem-list entries. The 18% are coding errors (wrong hierarchy branch chosen by the EHR interface). PPV of 0.82 for the incremental patients informs whether to include them with a sensitivity analysis or require a corroborating encounter diagnosis.

Result

SNOMED CT descendant expansion identifies 8960 patients vs 8200 from a flat ICD-10-CM code list, capturing 760 / 8200 = 0.0927 more patients (9.3% incremental capture). The 112-concept SNOMED set covers granular clinical subtypes absent from the 37-code ICD list. Version-pinning the SNOMED release and documenting provenance (problem-list vs encounter diagnosis) are required for reproducibility.

Runnable example

python implementation

Two operations: (1) build a transitive closure of the |is a| relationship over the RF2 Relationship file to find all descendants of a given SNOMED CT concept (here: 44054006 Diabetes mellitus type 2); (2) run the equivalent query over an OMOP...

import pandas as pd
import sqlite3

# ── Part 1: RF2-based transitive closure (native SNOMED CT) ──────────────────
# Download the US Edition RF2 from NLM (UMLS licence required).
# The Relationship snapshot file is typically named:
#   sct2_Relationship_Snapshot_US1000124_<YYYYMMDD>.txt

def load_isa_edges(rf2_relationship_file: str) -> dict[str, set[str]]:
    """Return a dict: child_sctid -> set of immediate parent SCTIDs (Is a only)."""
    IS_A = "116680003"  # SCTID for the |Is a| relationship type
    df = pd.read_csv(rf2_relationship_file, sep="\t", dtype=str, usecols=[
        "active", "sourceId", "destinationId", "typeId"
    ])
    df = df[(df["active"] == "1") & (df["typeId"] == IS_A)]
    parents: dict[str, set[str]] = {}
    for _, row in df.iterrows():
        parents.setdefault(row["sourceId"], set()).add(row["destinationId"])
    return parents

def descendants(root_sctid: str, parents: dict[str, set[str]]) -> set[str]:
    """Return all concepts that are a descendant of root_sctid (transitive Is a).
    Uses BFS over the child→parent graph traversed in reverse (child→parent → parent is ancestor).
    """
    # Build child map (parent → set of children) from the parent map
    children: dict[str, set[str]] = {}
    for child, pset in parents.items():
        for p in pset:
            children.setdefault(p, set()).add(child)

    visited: set[str] = set()
    queue = [root_sctid]
    while queue:
        node = queue.pop()
        for child in children.get(node, set()):
            if child not in visited:
                visited.add(child)
                queue.append(child)
    return visited  # does NOT include root_sctid itself

# Example usage (comment out if no RF2 files are available):
# parents = load_isa_edges("sct2_Relationship_Snapshot_US1000124_20240301.txt")
# dm_t2_descendants = descendants("44054006", parents)
# print(f"Found {len(dm_t2_descendants)} descendants of 44054006 |Diabetes mellitus type 2|")

# ── Part 2: OMOP CONCEPT_ANCESTOR query (SQLite example) ─────────────────────
# In a real OMOP environment, replace sqlite3 with your database driver.
# CONCEPT_ANCESTOR stores the pre-computed transitive closure of the Is a hierarchy.

DM_T2_OMOP_CONCEPT_ID = 201826  # OMOP standard concept_id for Diabetes mellitus type 2 (SNOMED 44054006)

def get_omop_descendants(con: sqlite3.Connection, ancestor_concept_id: int) -> pd.DataFrame:
    """Return all OMOP standard concept_ids that are descendants of a given concept.
    min_levels_of_separation = 0 includes the root; >= 1 for descendants only.
    """
    query = """
    SELECT
        ca.descendant_concept_id,
        c.concept_name,
        c.vocabulary_id,
        ca.min_levels_of_separation
    FROM concept_ancestor ca
    JOIN concept c ON c.concept_id = ca.descendant_concept_id
    WHERE ca.ancestor_concept_id = ?
      AND ca.min_levels_of_separation >= 1   -- descendants only, not the root itself
      AND c.invalid_reason IS NULL            -- active concepts only
    ORDER BY ca.min_levels_of_separation, c.concept_name
    """
    return pd.read_sql(query, con, params=(ancestor_concept_id,))

# Example usage (replace ':memory:' with path to OMOP SQLite or use your DB connection):
# con = sqlite3.connect(":memory:")   # placeholder
# df = get_omop_descendants(con, DM_T2_OMOP_CONCEPT_ID)
# print(f"OMOP descendant concepts: {len(df)}")
# print(df.head(10).to_string(index=False))
r implementation

Equivalent SNOMED CT descendant expansion in R: (1) BFS over the RF2 Relationship file to build descendants from native SNOMED CT data; (2) SQL query against OMOP CONCEPT_ANCESTOR using DBI/RSQLite for the OMOP-native approach. Both are minimal, commented...

library(dplyr)
library(DBI)
library(RSQLite)

# ── Part 1: RF2-based transitive closure (native SNOMED CT) ──────────────────
IS_A_TYPE <- "116680003"  # SCTID for Is a relationship type

load_isa_edges <- function(rf2_relationship_file) {
  # Read only the columns we need; typeId and active are character for SCTID fidelity
  df <- read.delim(rf2_relationship_file, sep = "\t", colClasses = "character",
                   stringsAsFactors = FALSE)
  df <- df[df$active == "1" & df$typeId == IS_A_TYPE,
           c("sourceId", "destinationId")]
  # Return named list: parent → vector of children (reverse of sourceId → destinationId)
  child_map <- split(df$sourceId, df$destinationId)
  child_map
}

descendants_bfs <- function(root_sctid, child_map) {
  # BFS from root down through child_map
  visited <- character(0)
  queue   <- root_sctid
  while (length(queue) > 0) {
    node  <- queue[1]
    queue <- queue[-1]
    kids  <- child_map[[node]]
    new   <- setdiff(kids, visited)
    visited <- c(visited, new)
    queue   <- c(queue, new)
  }
  visited  # excludes root itself
}

# Example usage (comment out if RF2 files not available):
# child_map <- load_isa_edges("sct2_Relationship_Snapshot_US1000124_20240301.txt")
# dm_t2_desc <- descendants_bfs("44054006", child_map)
# cat("Descendants of 44054006:", length(dm_t2_desc), "\n")

# ── Part 2: OMOP CONCEPT_ANCESTOR query (RSQLite / DBI) ─────────────────────
DM_T2_OMOP_ID <- 201826L  # OMOP concept_id for Diabetes mellitus type 2

get_omop_descendants <- function(con, ancestor_concept_id) {
  query <- "
    SELECT
      ca.descendant_concept_id,
      c.concept_name,
      c.vocabulary_id,
      ca.min_levels_of_separation
    FROM concept_ancestor ca
    JOIN concept c ON c.concept_id = ca.descendant_concept_id
    WHERE ca.ancestor_concept_id = ?
      AND ca.min_levels_of_separation >= 1
      AND c.invalid_reason IS NULL
    ORDER BY ca.min_levels_of_separation, c.concept_name
  "
  DBI::dbGetQuery(con, query, params = list(ancestor_concept_id))
}

# Example usage:
# con <- DBI::dbConnect(RSQLite::SQLite(), ":memory:")  # replace with real OMOP connection
# df  <- get_omop_descendants(con, DM_T2_OMOP_ID)
# cat("OMOP descendants:", nrow(df), "\n")
# print(head(df, 10))