Mapping MedDRA and WHODrug Terminologies Across Jurisdictions

An adverse event reported at a site in one country and a concomitant medication reported at a site in another only become comparable once both are resolved to the same coded term from the same terminology version. MedDRA (the Medical Dictionary for Regulatory Activities) codes clinical events and conditions; WHODrug codes medicinal products. Both are versioned dictionaries that sites, CROs, and safety databases adopt on their own schedules, so a global trial routinely has adverse events coded against three or four different MedDRA versions at once. This guide builds a version-aware mapping service that resolves terms from either dictionary to a canonical code with full provenance, so a safety signal is never missed because two sites described the same event one hierarchy level apart. It sits under Regulatory Taxonomy Standardization, part of Core Architecture & Regulatory Mapping for Clinical Trials, and its output feeds the same submission structures defined in FDA/EMA Submission Schema Design.

Why naive approaches fail

Treating MedDRA and WHODrug as flat lookup tables, or ignoring their versioning entirely, produces exactly the kind of aggregation error a safety review is designed to catch:

  • Comparing terms across MedDRA versions as if they were the same dictionary. MedDRA is updated twice a year, and a term’s Preferred Term, or even its position in the hierarchy, can change between versions. Two events coded a year apart under different versions are not directly comparable without resolving both to a common reference version first.
  • Treating the Lowest Level Term as the unit of analysis. Investigators and coders enter a Lowest Level Term (LLT) — the most granular, verbatim-adjacent level — but safety analysis and regulatory reporting operate on the Preferred Term (PT) it rolls up to, and further on the High Level Term, High Level Group Term, and System Organ Class above that. Aggregating raw LLTs from multiple sites without resolving to PT undercounts a signal that is really one event described several different ways.
  • Ignoring WHODrug’s own hierarchy. WHODrug codes a medicinal product down to its trade name and ingredient level; a naive exact-string match on the reported drug name misses branded generics and regional trade names that resolve to the same active ingredient.
  • No provenance on the mapping itself. When a canonical code is produced without recording which dictionary version and which source term produced it, a later dictionary update cannot be distinguished from a coding correction — an inspector asking “why did this event’s category change” has no answer.

Architecture overview

The service takes a raw coded term plus its declared dictionary version, resolves it through that version’s hierarchy, and returns a canonical code carrying both the resolved term and the provenance of how it got there.

MedDRA and WHODrug mapping pipeline Five stages left to right: raw term, version lookup, hierarchy resolution, canonicalize, and provenance output. Each stage feeds the next with a single arrow. Raw term Version Hierarchy Canonical Provenance

The MedDRA hierarchy resolves upward through five fixed levels — LLT to PT to High Level Term (HLT) to High Level Group Term (HLGT) to System Organ Class (SOC) — and a coded event always carries exactly one path up that chain within a given version. WHODrug resolves similarly from a specific trade-name entry down to an ingredient level, so two differently branded products can still canonicalize to the same active substance.

Setup and configuration

Neither dictionary is redistributable in this guide — both MedDRA and WHODrug are licensed terminologies — so the service is built against a local, licensed extract loaded from a path in the environment, keyed by version.

pip install "pydantic>=2.6"
"""Environment-driven terminology store configuration."""
from __future__ import annotations

import os
from dataclasses import dataclass


def required(name: str) -> str:
    value = os.environ.get(name)
    if not value:
        raise RuntimeError(f"Missing required environment variable: {name}")
    return value


@dataclass(frozen=True)
class TerminologyConfig:
    meddra_extract_root: str  # directory of licensed, version-partitioned MedDRA extracts
    whodrug_extract_root: str  # directory of licensed, version-partitioned WHODrug extracts
    reference_meddra_version: str  # the version safety analysis reports against
    reference_whodrug_version: str

    @classmethod
    def from_env(cls) -> "TerminologyConfig":
        return cls(
            meddra_extract_root=required("MEDDRA_EXTRACT_ROOT"),
            whodrug_extract_root=required("WHODRUG_EXTRACT_ROOT"),
            reference_meddra_version=required("MEDDRA_REFERENCE_VERSION"),
            reference_whodrug_version=required("WHODRUG_REFERENCE_VERSION"),
        )

Full working implementation

The core model separates a raw coded observation from its resolved, canonical form, and the mapper never guesses a version — an observation without one is rejected rather than assumed to be current.

"""Version-aware MedDRA and WHODrug term resolution."""
from __future__ import annotations

import json
import logging
from dataclasses import dataclass
from enum import Enum

logger = logging.getLogger("taxonomy.terminology_mapper")


class Dictionary(str, Enum):
    MEDDRA = "meddra"
    WHODRUG = "whodrug"


@dataclass(frozen=True)
class CodedObservation:
    """A term as coded at a site, tied to the dictionary version in force there."""

    dictionary: Dictionary
    version: str
    source_code: str  # e.g. a MedDRA LLT code or a WHODrug drug record number


@dataclass(frozen=True)
class MedDraHierarchy:
    llt_code: str
    llt_name: str
    pt_code: str
    pt_name: str
    hlt_code: str
    hlgt_code: str
    soc_code: str


@dataclass(frozen=True)
class WhoDrugEntry:
    drug_record_number: str
    trade_name: str
    ingredient_code: str
    ingredient_name: str


@dataclass(frozen=True)
class CanonicalTerm:
    dictionary: Dictionary
    source_version: str
    reference_version: str
    canonical_code: str
    canonical_name: str
    provenance: dict


class UnresolvedTermError(RuntimeError):
    """Raised when a source code has no entry in its declared dictionary version."""


class TerminologyMapper:
    def __init__(
        self,
        meddra_lookup: dict[tuple[str, str], "MedDraHierarchy"],
        whodrug_lookup: dict[tuple[str, str], "WhoDrugEntry"],
        reference_meddra_version: str,
        reference_whodrug_version: str,
    ) -> None:
        self._meddra_lookup = meddra_lookup
        self._whodrug_lookup = whodrug_lookup
        self._reference_meddra_version = reference_meddra_version
        self._reference_whodrug_version = reference_whodrug_version

    def resolve(self, observation: "CodedObservation") -> "CanonicalTerm":
        if observation.dictionary is Dictionary.MEDDRA:
            return self._resolve_meddra(observation)
        return self._resolve_whodrug(observation)

    def _resolve_meddra(self, observation: "CodedObservation") -> "CanonicalTerm":
        key = (observation.version, observation.source_code)
        hierarchy = self._meddra_lookup.get(key)
        if hierarchy is None:
            raise UnresolvedTermError(
                f"LLT {observation.source_code!r} not found in MedDRA {observation.version}"
            )
        canonical = CanonicalTerm(
            dictionary=Dictionary.MEDDRA,
            source_version=observation.version,
            reference_version=self._reference_meddra_version,
            canonical_code=hierarchy.pt_code,
            canonical_name=hierarchy.pt_name,
            provenance={
                "llt_code": hierarchy.llt_code,
                "llt_name": hierarchy.llt_name,
                "hlt_code": hierarchy.hlt_code,
                "hlgt_code": hierarchy.hlgt_code,
                "soc_code": hierarchy.soc_code,
            },
        )
        self._record_mapping(observation, canonical)
        return canonical

    def _resolve_whodrug(self, observation: "CodedObservation") -> "CanonicalTerm":
        key = (observation.version, observation.source_code)
        entry = self._whodrug_lookup.get(key)
        if entry is None:
            raise UnresolvedTermError(
                f"drug record {observation.source_code!r} not found in WHODrug {observation.version}"
            )
        canonical = CanonicalTerm(
            dictionary=Dictionary.WHODRUG,
            source_version=observation.version,
            reference_version=self._reference_whodrug_version,
            canonical_code=entry.ingredient_code,
            canonical_name=entry.ingredient_name,
            provenance={
                "drug_record_number": entry.drug_record_number,
                "trade_name": entry.trade_name,
            },
        )
        self._record_mapping(observation, canonical)
        return canonical

    @staticmethod
    def _record_mapping(observation: "CodedObservation", canonical: "CanonicalTerm") -> None:
        event = {
            "action": "term_mapped_to_canonical",
            "dictionary": observation.dictionary.value,
            "source_version": observation.version,
            "source_code": observation.source_code,
            "canonical_code": canonical.canonical_code,
            "reference_version": canonical.reference_version,
            "provenance": canonical.provenance,
        }
        logger.info(json.dumps(event, sort_keys=True))

Every resolution records both the input version and the reference version it was mapped toward, plus the full intermediate hierarchy or drug record — so a later reviewer can see not just the final Preferred Term or ingredient, but exactly which lowest-level entry in which version produced it.

Validation and edge-case handling

  • A version mismatch is not an error to auto-correct. If a site’s declared MedDRA version has been retired from the local extract, resolve against the version the site actually used and never silently substitute the reference version — substituting changes the mapping without a record of why, exactly the kind of undocumented decision an audit flags.
  • PT changes between versions must be tracked, not overwritten. When the reference version is bumped, re-resolve historical observations against the new reference explicitly and store the result as a new canonicalization rather than mutating the old one, preserving what the term resolved to at the time of the original safety review.
  • WHODrug ingredient-level rollup can combine distinct formulations. Two products with different routes of administration or strengths can share an ingredient code; when a safety signal depends on formulation, keep the drug-record-level detail in provenance rather than analyzing only the canonical ingredient.
  • A code absent from every loaded version is a data problem, not a mapping problem. UnresolvedTermError should route the observation to a coding-review queue rather than being caught and ignored — a term that fails to resolve typically means the original entry was mistyped or the correct extract has not been loaded yet.

Testing and verification

Tests build small in-memory lookup tables so the resolution logic is verified without depending on the licensed extracts themselves.

"""Tests for MedDRA and WHODrug term resolution."""
from __future__ import annotations

import pytest


@pytest.fixture
def mapper() -> "TerminologyMapper":
    meddra_lookup = {
        ("27.0", "10047438"): MedDraHierarchy(
            llt_code="10047438", llt_name="Vomiting NOS",
            pt_code="10047700", pt_name="Vomiting",
            hlt_code="10047697", hlgt_code="10017947", soc_code="10017947",
        ),
    }
    whodrug_lookup = {
        ("2024Q3", "D00001"): WhoDrugEntry(
            drug_record_number="D00001", trade_name="Brandopan",
            ingredient_code="I0234", ingredient_name="Paracetamol",
        ),
    }
    return TerminologyMapper(
        meddra_lookup=meddra_lookup,
        whodrug_lookup=whodrug_lookup,
        reference_meddra_version="27.0",
        reference_whodrug_version="2024Q3",
    )


def test_meddra_llt_resolves_to_pt(mapper: "TerminologyMapper") -> None:
    observation = CodedObservation(Dictionary.MEDDRA, version="27.0", source_code="10047438")
    canonical = mapper.resolve(observation)
    assert canonical.canonical_code == "10047700"
    assert canonical.provenance["llt_code"] == "10047438"


def test_whodrug_trade_name_resolves_to_ingredient(mapper: "TerminologyMapper") -> None:
    observation = CodedObservation(Dictionary.WHODRUG, version="2024Q3", source_code="D00001")
    canonical = mapper.resolve(observation)
    assert canonical.canonical_code == "I0234"
    assert canonical.canonical_name == "Paracetamol"


def test_missing_version_raises(mapper: "TerminologyMapper") -> None:
    observation = CodedObservation(Dictionary.MEDDRA, version="26.1", source_code="10047438")
    with pytest.raises(UnresolvedTermError):
        mapper.resolve(observation)

The first two tests confirm each dictionary resolves to the right canonical level with its provenance intact; the third confirms a version the mapper has no extract for fails loudly rather than falling back to a possibly-wrong match.

FAQ

Why resolve to the Preferred Term instead of keeping the Lowest Level Term?

Safety analysis and aggregate regulatory reporting operate at the Preferred Term level because it is the level MedDRA is designed to be counted and compared at across sources; the Lowest Level Term is closer to verbatim wording and several different LLTs commonly map to the same PT, which is exactly the aggregation a raw LLT count would miss.

Can two different WHODrug trade names really be the same underlying signal?

Yes. Regional branding and generic equivalents of the same active ingredient are common in a global trial, and resolving to the ingredient level is what lets a safety review see that the same substance was reported under different trade names at different sites.

What happens when MedDRA releases a new version mid-study?

Historical observations keep their original version and mapping; a new reference version is adopted going forward, and if a re-analysis against the new reference is required, it is run explicitly and recorded as a new canonicalization rather than replacing the original record.