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.
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.
Related
- CDISC SDTM Transformation Pipelines — the parent design and the shared USUBJID/–SEQ derivations this walkthrough applies to DM.
- Clinical Data Architecture & EDC Standards — the reference architecture this transformation sits within.
- Mapping Adverse Events to the SDTM AE Domain — the sibling Events-class domain that joins to these DM subjects.
- ADaM Dataset Derivation for Analysis — where DM reference dates seed the ADSL analysis population and treatment variables.
- Mapping EDC Forms to CDASH Standards Step by Step — the upstream acquisition mapping that feeds clean demographics into DM.