Building the SDTM DM Domain from EDC Demographics

The Demographics (DM) domain is the first dataset a reviewer opens and the one every other SDTM domain joins back to, yet it is where a subtle defect does the most damage: a USUBJID composed one way in DM and another way in AE silently orphans safety records, and an AGE derived from a partial birth date can drift by a year across a resubmission. This deep-dive is the tactical companion to CDISC SDTM Transformation Pipelines and sits within the wider Clinical Data Architecture & EDC Standards reference, where the EDC is an immutable, read-only source of truth. It is written for clinical data managers and Python ETL engineers who need a deterministic, defensible procedure for turning an EDC demographics form into the special-purpose DM domain — USUBJID, the RF-- reference dates, AGE/AGEU, the demographic controlled terminology, and the treatment ARM variables — without watering down the audit trail.

The DM Build Flow

DM is a special-purpose domain: one record per subject, no --SEQ, but carrying the reference dates and treatment assignments that anchor every other domain. The flow below builds it in six ordered steps, each isolating a single derivation.

The six-step SDTM DM build flow A vertical pipeline of six numbered stages that build the DM domain from an EDC demographics form. First USUBJID is composed from STUDYID, SITEID and SUBJID. Second the reference dates RFSTDTC, RFICDTC and RFXSTDTC are derived. Third AGE and AGEU are derived from BRTHDTC with a partial-date imputation flag. Fourth SEX, RACE and ETHNIC are harmonized against controlled terminology. Fifth the treatment arm variables ARM, ACTARM and ARMCD are assigned. Sixth the DM record is emitted and multi-select race is routed to SUPPDM as RACEMULTI. 1 2 3 4 5 6 Compose USUBJID STUDYID · SITEID · SUBJID Derive reference dates RFSTDTC · RFICDTC · RFXSTDTC Derive AGE + AGEU from BRTHDTC · imputation flag Harmonize terminology SEX · RACE · ETHNIC Assign treatment arm ARM · ACTARM · ARMCD Emit DM + SUPPDM multi-select race → RACEMULTI

Why the DM Domain Is Deceptively Hard

DM looks trivial — one row per subject — but it concentrates the study’s most load-bearing keys. USUBJID is the join column for the entire submission, so any inconsistency between how DM builds it and how the Findings and Events domains build it breaks referential integrity everywhere at once. The reference dates (RFSTDTC, RFENDTC, RFICDTC, RFXSTDTC) are not captured directly on the demographics form; they are derived from other domains and disposition data, and they in turn anchor every relative-day calculation downstream. AGE is a derivation from BRTHDTC, which is frequently collected as a partial date for privacy reasons, forcing an explicit imputation rule that must be reproducible and documented. And the demographic descriptors SEX, RACE, and ETHNIC are governed by CDISC controlled terminology whose codelists are narrow and unforgiving. Each of these is a place where a silent defect is introduced or prevented, which is why the procedure below is deterministic rather than best-effort, following the SDTM standard and its special-purpose domain rules.

Step 1: Compose USUBJID and Subject Keys

Build USUBJID from STUDYID, SITEID, and SUBJID in a fixed order, and compose it exactly the same way here as in every other domain. This is the single most important line in the whole pipeline: the reference implementation lives in the shared derivation used across domains, and DM must not reinvent it.

# ALCOA+ (Consistent): USUBJID composed identically to every other domain,
# so DM is the referential anchor the whole submission joins back to.
import pandas as pd

def build_dm_keys(edc: pd.DataFrame, studyid: str) -> pd.DataFrame:
    dm = pd.DataFrame(index=edc.index)
    dm["STUDYID"] = studyid
    dm["DOMAIN"] = "DM"
    dm["SITEID"] = edc["SITEID"].astype("string")
    dm["SUBJID"] = edc["SUBJID"].astype("string")
    # Fixed composition order — never reorder these parts.
    dm["USUBJID"] = studyid + "-" + dm["SITEID"] + "-" + dm["SUBJID"]
    return dm

Reading SITEID and SUBJID as nullable string preserves leading zeros that an integer coercion would destroy — a SUBJID of 007 must not become 7, or it will never join to its AE records.

Step 2: Derive the RF-- Reference Dates

The reference dates come from disposition and exposure data, not the demographics form. RFSTDTC is the datetime of first study treatment, RFENDTC the last, RFICDTC the informed-consent date, and RFXSTDTC the first study-treatment exposure. Derive each as an ISO-8601 string, preserving partials, and leave them null for subjects who were never treated (screen failures).

# 21 CFR Part 11 (Accurate): reference dates are derived from EX/DS, kept as
# ISO-8601, and left null for the never-treated — never zero-filled.
def derive_reference_dates(dm: pd.DataFrame, ex: pd.DataFrame,
                           ds: pd.DataFrame) -> pd.DataFrame:
    first_ex = ex.sort_values("EXSTDTC").groupby("USUBJID")["EXSTDTC"].first()
    last_ex = ex.sort_values("EXENDTC").groupby("USUBJID")["EXENDTC"].last()
    consent = ds[ds["DSDECOD"] == "INFORMED CONSENT OBTAINED"] \
        .groupby("USUBJID")["DSSTDTC"].first()
    dm["RFXSTDTC"] = dm["USUBJID"].map(first_ex)
    dm["RFSTDTC"] = dm["RFXSTDTC"]                 # first treatment == first exposure
    dm["RFENDTC"] = dm["USUBJID"].map(last_ex)
    dm["RFICDTC"] = dm["USUBJID"].map(consent)
    return dm

Mapping through groupby keeps the derivation vectorized and null-safe: a subject absent from ex simply maps to NaN, which is exactly the correct RFSTDTC for someone who never received treatment.

Step 3: Derive AGE and AGEU with Partial-Date Imputation

AGE is the whole number of years between BRTHDTC and RFICDTC (or RFSTDTC per the study’s SAP). When BRTHDTC is a partial date — commonly year-only or year-month — apply a documented imputation rule (impute to mid-point or to the reference conventions in the study’s data-handling plan) and set an imputation flag so the derivation is transparent.

# ALCOA+ (Accurate, Traceable): AGE from BRTHDTC with an explicit, documented
# partial-date imputation rule; the imputation is flagged, never hidden.
from datetime import date

def derive_age(brthdtc: str | None, ref: str | None) -> tuple[int | None, bool]:
    if not brthdtc or not ref:
        return None, False
    imputed = len(brthdtc) < 10                    # partial date -> impute
    b = brthdtc + ("-07-01"[len(brthdtc) - 4:] if imputed else "")  # mid-year/-month
    by, bm, bd = (int(x) for x in b.split("-"))
    ry, rm, rd = (int(x) for x in ref[:10].split("-"))
    age = ry - by - ((rm, rd) < (bm, bd))          # birthday-not-yet-reached adjust
    return age, imputed

def apply_age(dm: pd.DataFrame) -> pd.DataFrame:
    out = dm["BRTHDTC"].combine(dm["RFICDTC"], lambda b, r: derive_age(b, r))
    dm["AGE"] = [a for a, _ in out]
    dm["AGEU"] = "YEARS"                            # CT: AGEU is always populated with AGE
    dm["_AGE_IMPUTED"] = [f for _, f in out]       # -> SUPPDM if True
    return dm

AGEU must be populated whenever AGE is (the CDISC rule pairs them), and the _AGE_IMPUTED flag carries forward to SUPPDM so a reviewer can see precisely which ages were derived from partial birth dates.

Step 4: Harmonize SEX, RACE, and ETHNIC Terminology

These three variables are strict controlled terminology. Map the EDC’s collected values against the pinned CDISC codelists (SEX, RACE, ETHNIC) and route any miss to the query workflow rather than defaulting it.

# ALCOA+ (Accurate): demographic descriptors harmonized against pinned CT;
# a miss is tagged and queried, never coerced into a codelist value.
SEX_CT = {"Male": "M", "Female": "F", "Unknown": "U", "Undifferentiated": "UNDIFFERENTIATED"}
ETHNIC_CT = {"Hispanic or Latino": "HISPANIC OR LATINO",
             "Not Hispanic or Latino": "NOT HISPANIC OR LATINO"}

def harmonize_demographics(dm: pd.DataFrame) -> pd.DataFrame:
    dm["SEX"] = dm["SEX_RAW"].map(lambda v: SEX_CT.get(v, f"__UNMAPPED__:{v}"))
    dm["ETHNIC"] = dm["ETHNIC_RAW"].map(lambda v: ETHNIC_CT.get(v, f"__UNMAPPED__:{v}"))
    return dm

Keeping the codelists as explicit dictionaries — pinned to the study’s CT version — makes the harmonization reviewable and reproducible, the same discipline applied when mapping EDC forms to CDASH standards upstream.

Step 5: Assign ARM, ACTARM, ARMCD, and COUNTRY

ARM/ARMCD is the planned treatment arm and ACTARM/ACTARMCD the actual arm received; they diverge when a subject is dosed off-protocol. Subjects who screen-fail or are never randomized get the special ARMCD value SCRNFAIL or NOTASSGN with ARM set to the matching null-flavor text, never left blank.

# SDTM rule (Complete): planned vs actual arm are distinct; unrandomized
# subjects get NOTASSGN/SCRNFAIL, never an empty ARM.
def assign_arms(dm: pd.DataFrame, rand: pd.DataFrame) -> pd.DataFrame:
    planned = rand.set_index("USUBJID")["ARMCD"]
    dm["ARMCD"] = dm["USUBJID"].map(planned)
    dm["ARMCD"] = dm.apply(
        lambda r: r["ARMCD"] if pd.notna(r["ARMCD"])
        else ("SCRNFAIL" if pd.isna(r["RFSTDTC"]) else "NOTASSGN"), axis=1)
    arm_label = {"SCRNFAIL": "Screen Failure", "NOTASSGN": "Not Assigned"}
    dm["ARM"] = dm["ARMCD"].map(lambda c: arm_label.get(c, ARMCD_TO_ARM.get(c)))
    dm["ACTARMCD"] = dm["USUBJID"].map(actual_arm).fillna(dm["ARMCD"])
    dm["COUNTRY"] = dm["SITEID"].map(SITE_COUNTRY)  # ISO 3166-1 alpha-3 CT
    return dm

COUNTRY is itself controlled terminology (ISO 3166-1 alpha-3), so derive it from a site-to-country lookup rather than free text, and set DMDTC to the demographics collection date.

Step 6: Emit DM and Route Multi-Select RACE to SUPPDM

RACE allows only one value in the parent DM record. When a subject selects multiple races, put the standard RACE value MULTIPLE in DM and record each individual selection in SUPPDM under QNAM RACEMULTI, keyed back to the subject.

# SDTM rule (Complete): multi-select race collapses to RACE=MULTIPLE in DM,
# with each selection preserved in SUPPDM as RACEMULTI.
def build_suppdm(dm: pd.DataFrame, race_multi: dict[str, list[str]]) -> pd.DataFrame:
    supp = []
    for usubjid, races in race_multi.items():
        for race in races:
            supp.append({"STUDYID": dm["STUDYID"].iat[0], "RDOMAIN": "DM",
                         "USUBJID": usubjid, "IDVAR": "", "IDVARVAL": "",
                         "QNAM": "RACEMULTI", "QLABEL": "Race, Multiple Selection",
                         "QVAL": race, "QORIG": "CRF", "QEVAL": "SPONSOR"})
    # age-imputation flag also travels to SUPPDM
    for usubjid in dm.loc[dm["_AGE_IMPUTED"], "USUBJID"]:
        supp.append({"STUDYID": dm["STUDYID"].iat[0], "RDOMAIN": "DM",
                     "USUBJID": usubjid, "IDVAR": "", "IDVARVAL": "",
                     "QNAM": "AGEIMP", "QLABEL": "Age Imputed from Partial Date",
                     "QVAL": "Y", "QORIG": "DERIVED", "QEVAL": "SPONSOR"})
    return pd.DataFrame(supp)

Because DM has no --SEQ, its SUPPDM records key on USUBJID alone with empty IDVAR/IDVARVAL — a detail Pinnacle 21 checks explicitly.

Verification and Audit Trail

Confirming DM is correct is a regulated activity. Capture this evidence on every run so the build can be reconstructed at inspection:

Evidence Field / check Regulatory purpose
Referential anchor Every domain’s USUBJID ∈ DM USUBJID Proves DM anchors the submission (Consistent)
Age derivation AGE, AGEU, AGEIMP in SUPPDM Reconstructs AGE from BRTHDTC (Accurate)
CT membership SEX/RACE/ETHNIC/COUNTRY in codelist No __UNMAPPED__ reaches output (Accurate)
Arm completeness No null ARMCD; SCRNFAIL/NOTASSGN used Screen failures explicit (Complete)
Determinism assert_frame_equal over two runs Reproducible build (Original)

Archive the Pinnacle 21 DM report and the SUPPDM register alongside the pinned CT version stamped in define.xml.

Edge Cases and Vendor-Specific Gotchas

Partial and missing birth dates. Year-only BRTHDTC is common where sites redact day and month for privacy. Never zero-fill to -01-01; apply the study data-handling plan’s mid-point rule, set the AGEIMP flag, and route the imputation to SUPPDM so AGE is defensible and reproducible.

Screen failures and not-assigned arms. A subject who consents but never randomizes has a real DM record with ARMCD=SCRNFAIL and null RF-- dates. Leaving ARM blank is a conformance reject; populate the null-flavor arm text so the subject is accounted for.

Multi-select race collapse. Some EDC builds emit one row per selected race. Collapse them to RACE=MULTIPLE in the single DM record and preserve every selection in SUPPDM RACEMULTI — overwriting to a single race silently discards collected data.

Frequently Asked Questions

Does the DM domain have a --SEQ variable?

No. DM is a special-purpose domain with exactly one record per subject, so it has no DMSEQ. Its SUPPDM records therefore key on USUBJID with empty IDVAR and IDVARVAL, unlike the Findings and Events domains that key their supplementals on --SEQ.

How should I derive AGE from a year-only birth date?

Apply the imputation rule documented in the study data-handling plan — commonly imputing to the mid-point of the known granularity — then compute whole years to the reference date and set an imputation flag that travels to SUPPDM. Never zero-fill the partial date to January 1, and never leave the imputation undocumented, because AGE must reconstruct identically on a resubmission.

What ARMCD do I use for a screen failure?

Use the CDISC controlled-terminology value SCRNFAIL for a subject who never randomized, or NOTASSGN for one not assigned to any arm, and populate ARM with the matching descriptive text. The record still exists in DM with null RF-- reference dates; leaving ARM blank is a conformance failure.

Where does a subject's multiple race selection go?

Set RACE=MULTIPLE in the parent DM record and record each individual selection as a separate SUPPDM row under QNAM=RACEMULTI, keyed to the subject. This keeps the single-value parent variable conformant while preserving every collected selection for the reviewer.

Why must USUBJID be composed the same way as in other domains?

USUBJID is the join key for the entire submission, so if DM builds it as STUDYID-SITEID-SUBJID while AE builds it differently, the safety records orphan and referential integrity checks fail across the study. Composing it through one shared, versioned derivation guarantees every domain resolves to the same subject.