1. The Problem: “The Hierarchy Is Not My Cohort”
You resolve clinical text to OMOP concepts and now you want to analyze them as a group. Say you want a cohort of patients with abnormal findings in the lung. Your pipeline surfaced two concepts:
Both belong in the cohort. Neither is an ancestor of the other. And the OMOP hierarchy is fixed - you cannot add a “my abnormal lung findings” node to
concept_ancestor.
So it looks like a binary choice: accept the vocabulary’s predefined groupings, or hand-enumerate every relevant concept ID forever.
It is not a binary choice. There is a third option, and it is the one OHDSI tooling (ATLAS, Circe) is built on: a concept set expression.
2. The Core Concept: Store the Recipe, Not the List
A concept set expression is a small, declarative recipe:The OMOPHub API is read-only: it expands expressions, it does not store them. Persistence, and the union/intersect/exclude set algebra, live in your application. That is a handful of Python
set operations - see the recipe below.3. Step One: Find the Grouper (Do Not Guess It)
The instinct is to eyeball a parent concept. Resist it. For our lung example, the obvious guess is257907 “Disorder of lung” - and it is wrong:
“Disorder of lung” misses “Solitary nodule of lung” entirely, because that concept is a SNOMED Clinical Finding, not a Disorder. A cohort built on the intuitive grouper would silently drop every patient whose lung abnormality was coded as a finding.
Compute the grouper instead. Fetch the ancestors of each seed concept and intersect them - the shared ancestors are your candidates, ranked by how many seeds they cover and how tightly:
4249557 Radiologic infiltrate of lung, 4200027 Cavitation of lung, 37206719 Nodule of lung - this returns:
Read two things off it.
The intuitive grouper stays wrong as the seed set grows.
257907 “Disorder of lung” still misses a seed at five seeds, just as it did at two. More seeds do not rescue a grouper that sits on the wrong branch.
There is a size cliff, and it is your stopping rule. One step up from “Lung finding” costs +940 concepts for zero extra coverage; the step after that is a 5.9x blow-up. Climbing all the way to “Clinical finding” costs 79x for nothing at all. So: climb while coverage improves, and stop the moment the descendant count jumps disproportionately.
4. Step Two: Expand, Then Subtract
Hierarchy expansion alone is over-inclusive. The descendants of “Lung finding” include:
These are the opposite of an abnormal finding. A naive
WHERE condition_concept_id IN (descendants_of_4115260) produces a cohort that includes patients whose lungs were explicitly documented as normal.
Note the domain column, because it changes how much work each filter is doing. The domain filter in the next section removes the first three on its own. 4064736 “Lung function testing normal” is a Condition, so it survives every filter and lands in the cohort - which is exactly why the exclude arm is not optional. A normal finding really can be Condition-domain, and no amount of domain or standard-concept filtering will catch it.
This is precisely why an expression has an exclude arm. The full recipe:
descendants() from the previous step:
- It pages.
/descendantsreturns one page per call. A helper that reads only the first response caps every grouper at 200 concepts - your cohort would quietly contain a fraction of what it should, and yourexcludebranches would mostly not resolve either. - It filters the rule roots, not just their descendants. “Lung finding” is a Clinical Finding in the
Conditiondomain, so it survives. But a classification grouper (standard_concept = 'C', see below) would be added unconditionally by a naiveout.add(rule["concept_id"])and land in a cohort where it is not legal.
set difference is the entire “set algebra” you need. Union two expressions by unioning their expansions; intersect them the same way.
5. Two Filters You Must Not Skip
Filter by domain. Descendants of a Condition-domain grouper do not all stay in Condition. “Lung finding” expands into 2,453Condition concepts and 17 Observation concepts. If you are populating a condition cohort, domain_id must match the CDM table you are querying, or your IN clause will contain concept IDs that can never appear in condition_occurrence.
Filter to standard concepts. Only standard_concept = 'S' concepts appear in the *_concept_id columns of an OMOP CDM. The column holds exactly three values:
Note the third value is
NULL, not the string 'N'. OMOP has no 'N', so standard_concept = 'N' matches nothing and standard_concept != 'S' silently drops every non-standard concept (NULL fails any comparison). Test for it with IS NULL. The keep() helper above sidesteps this by checking != "S" in Python, where None != "S" behaves as you would expect - but the same expression in SQL does not.
Classification concepts - SNOMED’s higher-level groupers, ICD-10 chapters, ATC drug classes - are legitimate and often excellent include seeds. But they are never written to a CDM event table. Expanding a C grouper and forgetting to filter its expansion to S is the single most common OMOP cohort bug.
6. When Your Pipeline Lands in the Wrong Domain
Everything above assumes your seeds are in the domain you are analyzing. A text-extraction pipeline routinely breaks that assumption: it resolves a phrase to a concept with exactly the right name sitting in the wrong domain. The natural next question is whether some equivalence mapping can convert it back. It cannot. OMOP has no relationship that moves a concept to its equivalent in another domain, because there is no such thing - domain is a property of the concept, and two same-named concepts in different domains are genuinely different things sharing a string. Measured against release 2026.1:- 8,467 pairs of standard concepts share a name across different domains. None are linked by an equivalence relationship. The 765 that are linked at all are overwhelmingly
Has asso morph- a semantic attribute, not equivalence. - Every
eqrelationship in the vocabulary (SNOMED - CPT4 eq,LOINC - SNOMED eq,RxNorm - ATC name, …) crosses vocabularies, never domains. - Of the 3,518,557
Maps torows that originate from a standard concept, 100% point at the concept itself. Once you hold a standard concept,Maps tois a no-op. It relocates you only when the source is a non-standard code.
Why this is worse than a mislabeled domain
Searching for “chronic obstructive pulmonary disease” without a domain filter returns two concepts with identical names:36308250 is a LOINC answer-list value - what someone ticked on a questionnaire, not a diagnosis. It has no relationship to 255573, its only Maps to points to itself, and in concept_ancestor it has exactly one ancestor and one descendant: itself.
It is hierarchically isolated. So a grouping built from it does not come out wrong - it comes out empty, silently.
That is not a quirk of this one concept:
Meas Value is where most cross-domain name collisions live (2,289 with Observation, 518 with Condition). Drifting into it costs you the hierarchy entirely.
Fix it at resolution time
Pass the domain you are populating. It is one parameter, supported on/v1/search/semantic, /v1/search/concepts, and /v1/concepts/recommended:
The one partial exception
For morphology there is a usable recovery route. From aMorph Abnormality concept - which lives in Observation - Asso morph of reaches the conditions carrying that morphology:
Morph Abnormality concepts have such a route, and it is one-to-many. You get a set of related conditions, not “the equivalent concept” - a fan-out, not a converter.
7. Putting It Together
8. Scaling and Gotchas
Batch your lookups.POST /v1/concepts/hierarchy/batch takes up to 100 queries in a single request and counts as one call against your rate limit (2 req/s on Free). Expanding 500 groupers is five requests, not five hundred.
max_results explicitly on very large groupers. The ceiling is 5,000 descendants per concept. If a grouper is broad enough to approach that, split it into several narrower include seeds rather than relying on one enormous root.
Set max_levels=20 explicitly, always. It defaults to 10, and truncated does not flag depth truncation - only the row cap. A grouper whose tree runs deeper than 10 levels silently loses its deepest descendants while the response still reports truncated: false. This is the easiest way to build a quietly incomplete cohort. (A handful of SNOMED trees run deeper than 20, which is the API maximum; for those, seed from a lower node in the tree.)
Check the true descendant count before you expand. GET /v1/concepts/{concept_id}/level returns total_descendants without fetching the set - a cheap way to catch a grouper that is far broader than you intended.
include_invalid defaults to false. Deprecated concepts are excluded unless you ask for them. That is almost always what you want for a cohort; be deliberate if you override it.
9. Knowing When You Are Done
The obvious worry about all of this is that you never had the exhaustive list of terms to begin with. You do not need one - and you could not verify it if you had it. The grouper stabilizes long before the concept list does. The two seeds in section 3 and the five-seed set both select4115260, with the same ranking. Three or four clinically diverse seeds is typically enough to fix the altitude.
So replace “have I listed everything?” - unanswerable - with a convergence test: does my grouping already contain the things I have not thought of?
1
Back-trace a handful of seeds
Run
find_groupers and pick the candidate just before the size cliff.2
Ask what you are forgetting
Feed the same seeds to
POST /v1/concepts/recommended. It implements the OHDSI
Phoebe algorithm, suggesting concepts that co-occur with yours in real,
curated concept sets. Run GET /v1/search/semantic over the same scope as a
second, independent channel - it catches concepts worded differently from your
extracted terms, where Phoebe’s co-occurrence signal is thin.3
Measure what already falls inside
Check each suggestion against your expansion. A high hit rate means the grouper
is at the right altitude. For the five lung seeds, filtered to
domain_ids: ["Condition"] and standard_only: true: 71 distinct
recommendations, 64 of them (90%) already inside 4115260.4
Triage the remainder
The ones that fall outside are your entire review queue - seven, in this case,
each a real decision.
4154909 Abnormal radiologic density and 4027562
Radiologic infiltrate are genuine misses (in scope, but filed under the
radiology branch rather than the anatomy branch). 256449 Bronchiectasis,
4050884 Pleural plaque and 4061819 Aortopulmonary window are judgement
calls - airway, pleura, and vessel rather than lung parenchyma. One is
co-occurrence noise.5
Loop
Add the genuine misses as new
include seeds, re-expand, re-run. Stop when
the outside-list contains only things you are deliberately rejecting. That is
your termination condition.POST /v1/concepts/recommended returns data as an object keyed by source concept ID, not a flat list. A concept suggested by three seeds appears three times, so de-duplicate on concept_id before you count - the 84 rows in the lung example are 71 distinct concepts.
The expression grows by a line or two per round. The maintenance burden does not.