SDTM DM Domain in R with sdtm.oak: Build the Demographics Special-Purpose Class

Map a raw demographics and enrollment extract into a CDISC-conformant SDTM DM domain in R with sdtm.oak — the Special-Purpose class, one row per subject, USUBJID, the reference start date RFSTDTC, planned vs actual treatment arm, and the CT-coded AGE/SEX/RACE/ETHNIC

Pharma & Clinical

A complete, runnable tutorial for the SDTM DM (demographics) domain in R. Learn what the CDISC SDTM Special-Purpose class is (one row per subject, no topic variable, no sequence number), how it differs from the Findings, Events, and Interventions classes, then map a raw demographics extract into a conformant DM domain with sdtm.oak: generate_oak_id_vars() for record lineage, assign_ct() for the controlled-terminology SEX/RACE/ETHNIC, assign_no_ct() for the planned and actual treatment arms (ARM/ARMCD/ACTARM/ACTARMCD), assign_datetime() for the ISO 8601 reference dates (RFSTDTC/RFENDTC/BRTHDTC), a derived AGE, and derive_study_day() for DMDY — the subject spine every other domain joins on by USUBJID and clocks against RFSTDTC.

Published

July 1, 2026

Modified

July 7, 2026

TipKey takeaways
  • DM is a Special-Purpose domain — a fourth record shape, and the most foundational. The Findings, Events, and Interventions classes each hold many rows per subject; DM holds exactly one row per subject, with no topic variable and no sequence number.
  • DM is the subject spine. Every other domain joins to it by the unique subject identifier USUBJID, and every study day (VSDY, AEDY, EXDY…) is counted against DM’s reference start date RFSTDTC. Build DM wrong and every domain downstream is wrong.
  • The demographic descriptors are controlled. assign_ct() recodes the collected sex, race, and ethnicity to their CDISC standard terms (SEX, RACE, ETHNIC).
  • Planned and actual treatment are two different columns. ARM/ARMCD record the arm the subject was randomized to; ACTARM/ACTARMCD record the arm they actually received — they differ when a subject is mis-dosed, and both are copied with assign_no_ct().
  • The reference dates anchor the whole study clock. assign_datetime() builds the ISO 8601 RFSTDTC/RFENDTC/BRTHDTC; AGE is derived from the birth date; and derive_study_day() places DM’s own collection date on the clock — study day 1 is RFSTDTC itself, so a screening date lands on a negative day, never day 0.

Introduction

Every number in a submission — every adverse event, every lab value, every dose — hangs off one row per subject: who they are, which arm they were on, and the date the clock started. That row is the SDTM (Study Data Tabulation Model) DM (demographics) domain. What the site records is looser: an enrollment CRF (case report form) with a birth date, a sex ticked “Female”, a race and ethnicity picked from a list, a randomization arm, and a couple of key dates typed however the coordinator typed them. The job here is to turn that raw extract into a conformant DM domain — the one dataset every other domain in the study is built on top of.

This lesson does that mapping end to end in R with sdtm.oak, the pharmaverse package for raw-to-SDTM transformation. DM completes the SDTM class picture: the Findings domain (vital signs) records what was measured, the Events domain (adverse events) records what happened, the Interventions domain (drug exposure) records what was administered — and DM is the fourth class, the Special-Purpose one: not many rows of observations, but a single subject-level row. It is the subject spine the other three reference: each of them joins to DM by USUBJID and computes its study days against DM’s RFSTDTC. SDTM is the upstream of the clinical data flow — raw → SDTM → ADaM → tables, listings, and figures (TLF) — so a clean DM here is what lets the analysis layer build the subject-level ADSL dataset, which starts from exactly these columns.

Here is where we are headed — the conformant DM domain we will build, drawn as the baseline demographics it encodes, straight off the mapped result:

Dot plot of the mapped SDTM DM domain, one dot per subject. The three planned treatment arms (Placebo, Xanomeline Low Dose, Xanomeline High Dose) sit on the y axis; each subject's age is its position on the x axis, from about 55 to 74 years; dots are coloured by sex (azure for female, orange for male); a dark vertical crossbar marks each arm's mean age. Twelve dots in total — because DM has exactly one row per subject, each dot IS one DM record. The point is that a raw enrollment extract has become a tidy, subject-level demographics table, immediately readable as a baseline-characteristics figure.

Every dot there is one SDTM record — one subject, with an age, a sex, and a treatment arm. Because DM has exactly one row per subject, the dot is the record. By the end of this lesson you will have produced all of them, with their reference dates, from a raw extract. New to the wider pipeline? Start with the clinical-trial data flow, which maps where SDTM sits, and the CDISC standards, which places SDTM next to CDASH and ADaM.

What the SDTM Special-Purpose class is

SDTM — the Study Data Tabulation Model — is the CDISC (Clinical Data Interchange Standards Consortium) standard for organizing collected trial data into fixed domains. It is published as the SDTM foundational standard and the SDTM Implementation Guide (SDTMIG). Most observations fall into one of three general observation classes — but DM belongs to a fourth group, the Special-Purpose domains, and the difference is structural:

Observation class What it records Example domains Topic variable Rows
Interventions Things given to the subject EX (exposure), CM (con meds) --TRT one per dosing episode
Events Things that happened AE (adverse events), MH, DS --TERM one per event
Findings Measurements VS (vital signs), LB, EG --TESTCD one per measurement
Special-Purpose Subject-level / trial reference DM (demographics), CO, SE, SV (none) one per subject

The vital-signs lesson, the adverse-events lesson, and the drug-exposure lesson each mapped a general observation class, where a topic variable (a test code, a reported term, a treatment) names each record and a subject has many rows. DM is different. It has no topic variable — a subject is not a measurement, an event, or an intervention — and there is exactly one row per subject. That single row carries the subject’s identity, their planned and actual treatment, and the reference dates the rest of the study is timed against. It is not one observation among many; it is the anchor the many observations attach to.

A DM record is a set of subject-level fields grouped by role:

Variable role DM example What it carries
Identifiers STUDYID, USUBJID, DOMAIN, SUBJID, SITEID Study, the unique subject identifier, the domain code (DM), the sponsor’s subject id, and the site.
Subject descriptors AGE, AGEU, SEX, RACE, ETHNIC Who the subject is — age and its unit, sex, race, ethnicity.
Treatment ARMCD, ARM, ACTARMCD, ACTARM The planned arm (from randomization) and the actual arm (what the subject received).
Reference timing RFSTDTC, RFENDTC, BRTHDTC, DMDTC Reference start and end, birth date, and demographics-collection date — all ISO 8601.

Two of these do most of the load-bearing work for the whole study. USUBJID — the unique subject identifier — is the key every other domain joins on: an AE, a VS reading, and an EX episode all carry the same USUBJID, and that is how a reviewer links a subject’s event to their treatment arm. And RFSTDTC — the subject reference start date (typically the first dose or randomization date) — is the origin of the study clock: every domain’s --DY study day (VSDY, AEDY, EXDY) is counted from RFSTDTC. Get DM right and the whole study lines up; get USUBJID or RFSTDTC wrong and every join and every study day downstream is wrong. Mapping raw to SDTM here is filling those subject-level columns — some under controlled terminology (CT), CDISC-published codelists that fix the allowed values (SDTM Controlled Terminology), some copied verbatim, some derived.

The raw demographics extract and its controlled terminology

Two inputs drive a mapping: the raw extract and the study controlled terminology. sdtm.oak ships raw examples for concomitant medications and vital signs but not demographics, so we build a small raw enrollment extract in the shape an EDC (electronic data capture) system exports one — one wide row per subject, sponsor-chosen column names, dates in the format the form used. It follows the public CDISC pilot: a transdermal study drug (Xanomeline) titrated between a low and a high dose against a placebo arm.

library(sdtm.oak)
library(dplyr, warn.conflicts = FALSE)

# A raw demographics/enrollment extract: one row per subject, as collected
dm_raw <- data.frame(
  SUBJ    = c("1015","1023","1028","1033","1039","1044","1050","1074","1096","1103","1115","1120"),
  SITE    = c("701","701","701","701","702","702","702","703","703","704","704","705"),
  BRTHDAT = c("26 Dec 1950","22 Jul 1948","11 Jul 1942","10 Mar 1940","05 Sep 1955","19 Nov 1938",
              "02 Feb 1947","30 Apr 1952","14 Aug 1945","21 Jan 1958","08 Jun 1943","17 Oct 1949"),
  SEXR    = c("Female","Male","Male","Male","Female","Female","Male","Female","Male","Female","Female","Male"),
  RACER   = c("White","White","White","White","Black or African American","White","Asian","White",
              "White","Black or African American","White","American Indian or Alaska Native"),
  ETHR    = c("Hispanic or Latino","Hispanic or Latino","Not Hispanic or Latino","Not Hispanic or Latino",
              "Not Hispanic or Latino","Not Hispanic or Latino","Not Hispanic or Latino","Hispanic or Latino",
              "Not Hispanic or Latino","Not Hispanic or Latino","Not Hispanic or Latino","Not Hispanic or Latino"),
  CNTRY   = "USA",
  PARM    = c("Placebo","Placebo","Xanomeline High Dose","Xanomeline Low Dose","Placebo","Xanomeline High Dose",
              "Xanomeline Low Dose","Placebo","Xanomeline High Dose","Xanomeline Low Dose","Placebo","Xanomeline High Dose"),
  PARMCD  = c("Pbo","Pbo","Xan_Hi","Xan_Lo","Pbo","Xan_Hi","Xan_Lo","Pbo","Xan_Hi","Xan_Lo","Pbo","Xan_Hi"),
  AARM    = c("Placebo","Placebo","Xanomeline High Dose","Xanomeline Low Dose","Placebo","Xanomeline Low Dose",
              "Xanomeline Low Dose","Placebo","Xanomeline High Dose","Xanomeline Low Dose","Placebo","Xanomeline High Dose"),
  AARMCD  = c("Pbo","Pbo","Xan_Hi","Xan_Lo","Pbo","Xan_Lo","Xan_Lo","Pbo","Xan_Hi","Xan_Lo","Pbo","Xan_Hi"),
  RFSTDAT = c("02 Jan 2014","05 Aug 2012","19 Jul 2013","18 Mar 2014","11 Mar 2013","09 Apr 2013",
              "22 Sep 2013","03 Feb 2014","20 Feb 2014","28 Mar 2013","15 Jan 2014","06 Jun 2013"),
  RFENDAT = c("02 Jul 2014","02 Sep 2012","14 Jan 2014","14 Apr 2014","30 May 2013","30 Sep 2013",
              "18 Oct 2013","19 Jun 2014","30 Jun 2014","25 Jul 2013","15 Jul 2014","25 Nov 2013"),
  DMDAT   = c("26 Dec 2013","22 Jul 2012","11 Jul 2013","10 Mar 2014","04 Mar 2013","01 Apr 2013",
              "13 Sep 2013","27 Jan 2014","06 Feb 2014","20 Mar 2013","08 Jan 2014","30 May 2013"),
  stringsAsFactors = FALSE
)

dm_raw |>
  select(SUBJ, SITE, SEXR, RACER, PARM, AARM, RFSTDAT) |>
  head()
  SUBJ SITE   SEXR                     RACER                 PARM
1 1015  701 Female                     White              Placebo
2 1023  701   Male                     White              Placebo
3 1028  701   Male                     White Xanomeline High Dose
4 1033  701   Male                     White  Xanomeline Low Dose
5 1039  702 Female Black or African American              Placebo
6 1044  702 Female                     White Xanomeline High Dose
                  AARM     RFSTDAT
1              Placebo 02 Jan 2014
2              Placebo 05 Aug 2012
3 Xanomeline High Dose 19 Jul 2013
4  Xanomeline Low Dose 18 Mar 2014
5              Placebo 11 Mar 2013
6  Xanomeline Low Dose 09 Apr 2013

Each raw row is one subject: a birth date, the collected sex/race/ethnicity, the country and site, the planned arm (PARM/PARMCD) and the actual arm received (AARM/AARMCD), and the key study dates — the reference start (RFSTDAT), reference end (RFENDAT), and the demographics-collection date (DMDAT). Notice subject 1044: planned Xanomeline High Dose but actual Xanomeline Low Dose — a real mismatch we come back to. The controlled terminology maps each collected value to its standard CDISC term within a codelist. A real study CT ships as part of the SDTM specification; here we define the demographic codelists we need:

# Study controlled terminology: collected value -> CDISC standard term, by codelist
dm_ct <- data.frame(
  codelist_code   = c("C66731","C66731",                                  # SEX
                      "C74457","C74457","C74457","C74457",                # RACE
                      "C66790","C66790"),                                 # ETHNIC
  term_value      = c("F","M",
                      "WHITE","BLACK OR AFRICAN AMERICAN","ASIAN","AMERICAN INDIAN OR ALASKA NATIVE",
                      "HISPANIC OR LATINO","NOT HISPANIC OR LATINO"),
  collected_value = c("Female","Male",
                      "White","Black or African American","Asian","American Indian or Alaska Native",
                      "Hispanic or Latino","Not Hispanic or Latino"),
  term_preferred_term = NA_character_, term_synonyms = NA_character_,
  stringsAsFactors = FALSE
)

dm_ct |> select(codelist_code, collected_value, term_value)
  codelist_code                  collected_value
1        C66731                           Female
2        C66731                             Male
3        C74457                            White
4        C74457        Black or African American
5        C74457                            Asian
6        C74457 American Indian or Alaska Native
7        C66790               Hispanic or Latino
8        C66790           Not Hispanic or Latino
                        term_value
1                                F
2                                M
3                            WHITE
4        BLACK OR AFRICAN AMERICAN
5                            ASIAN
6 AMERICAN INDIAN OR ALASKA NATIVE
7               HISPANIC OR LATINO
8           NOT HISPANIC OR LATINO

collected_value is what the CRF recorded; term_value is the CDISC-standard term sdtm.oak will substitute. The three codelists are CDISC standard, published in SDTM Controlled Terminology: C66731 is the Sex codelist (SEX), C74457 the Race codelist (RACE), and C66790 the Ethnic Group codelist (ETHNIC). The ct_clst argument of every CT-aware function points at one of these codelist_code values.

Stamp record lineage with oak_id_vars

Because we map one raw field at a time and stitch the pieces together, sdtm.oak needs a stable key on every raw row. generate_oak_id_vars() adds three: oak_id (the row number), raw_source (which extract it came from), and patient_number.

dm_raw <- dm_raw |>
  generate_oak_id_vars(pat_var = "SUBJ", raw_src = "dm_raw")

dm_raw |>
  select(oak_id, raw_source, patient_number, SEXR, PARM) |>
  head()
  oak_id raw_source patient_number   SEXR                 PARM
1      1     dm_raw           1015 Female              Placebo
2      2     dm_raw           1023   Male              Placebo
3      3     dm_raw           1028   Male Xanomeline High Dose
4      4     dm_raw           1033   Male  Xanomeline Low Dose
5      5     dm_raw           1039 Female              Placebo
6      6     dm_raw           1044 Female Xanomeline High Dose

These three columns are the oak_id_vars. Every map below passes id_vars = oak_id_vars(), which tells sdtm.oak to join the new column back onto the right record by this key — the mechanism that lets partial maps combine without duplicating rows. For DM that guarantee matters twice over: because there is one row per subject, a join that duplicated a row would silently break the domain’s defining rule.

Map the demographic descriptors under controlled terminology

The subject descriptors SEX, RACE, and ETHNIC are each a collected value recoded through controlled terminology, so each uses assign_ct() (“assign”, because it copies a collected value; “ct”, because the value is recoded through a codelist) pointed at the codelist that governs it:

  • SEX — the Sex codelist C66731: Female/MaleF/M.
  • RACE — the Race codelist C74457: WhiteWHITE, Black or African American stays as its standard uppercase term, and so on.
  • ETHNIC — the Ethnic Group codelist C66790: Hispanic or LatinoHISPANIC OR LATINO.
dm <- assign_ct(raw_dat = dm_raw, raw_var = "SEXR", tgt_var = "SEX",
                ct_spec = dm_ct, ct_clst = "C66731") |>
  assign_ct(raw_dat = dm_raw, raw_var = "RACER", tgt_var = "RACE",
            ct_spec = dm_ct, ct_clst = "C74457", id_vars = oak_id_vars()) |>
  assign_ct(raw_dat = dm_raw, raw_var = "ETHR", tgt_var = "ETHNIC",
            ct_spec = dm_ct, ct_clst = "C66790", id_vars = oak_id_vars())

dm |>
  select(oak_id, patient_number, SEX, RACE, ETHNIC) |>
  head()
# A tibble: 6 × 5
  oak_id patient_number SEX   RACE                      ETHNIC                
   <int> <chr>          <chr> <chr>                     <chr>                 
1      1 1015           F     WHITE                     HISPANIC OR LATINO    
2      2 1023           M     WHITE                     HISPANIC OR LATINO    
3      3 1028           M     WHITE                     NOT HISPANIC OR LATINO
4      4 1033           M     WHITE                     NOT HISPANIC OR LATINO
5      5 1039           F     BLACK OR AFRICAN AMERICAN NOT HISPANIC OR LATINO
6      6 1044           F     WHITE                     NOT HISPANIC OR LATINO

Every collected value is now a standard CDISC term: SEX is F/M, RACE is WHITE, ETHNIC is HISPANIC OR LATINO. That is the point of controlled terminology — a reviewer, or an FDA validation tool, reads the same coded values across every study, regardless of how each site phrased them on the CRF. One SDTM column per collected descriptor, one record per subject.

Map the planned and actual treatment arms

DM carries treatment twice, and the distinction is one of the reasons DM exists at all. ARM/ARMCD are the planned arm — the treatment the subject was randomized to. ACTARM/ACTARMCD are the actual arm — the treatment the subject actually received. They are equal for most subjects, but diverge when a subject is enrolled or dosed differently than planned — a randomization or dosing error — and the safety analysis must know which is which. Both the long label (ARM) and its short code (ARMCD) come from the study’s Trial Arms definition, so they are study-defined values copied verbatim, not recoded through CDISC terminology — which means assign_no_ct() (“no_ct”, because there is no codelist to recode through):

dm <- dm |>
  assign_no_ct(raw_dat = dm_raw, raw_var = "PARM",   tgt_var = "ARM",      id_vars = oak_id_vars()) |>
  assign_no_ct(raw_dat = dm_raw, raw_var = "PARMCD", tgt_var = "ARMCD",    id_vars = oak_id_vars()) |>
  assign_no_ct(raw_dat = dm_raw, raw_var = "AARM",   tgt_var = "ACTARM",   id_vars = oak_id_vars()) |>
  assign_no_ct(raw_dat = dm_raw, raw_var = "AARMCD", tgt_var = "ACTARMCD", id_vars = oak_id_vars()) |>
  assign_no_ct(raw_dat = dm_raw, raw_var = "CNTRY",  tgt_var = "COUNTRY",  id_vars = oak_id_vars())

dm |>
  select(patient_number, ARMCD, ARM, ACTARMCD, ACTARM) |>
  filter(ARM != ACTARM)
# A tibble: 1 × 5
  patient_number ARMCD  ARM                  ACTARMCD ACTARM             
  <chr>          <chr>  <chr>                <chr>    <chr>              
1 1044           Xan_Hi Xanomeline High Dose Xan_Lo   Xanomeline Low Dose

One row comes back — subject 1044, planned Xanomeline High Dose but actually on Xanomeline Low Dose. That is exactly the case the two-column design is for: an efficacy analysis is usually run by planned arm (intention-to-treat), a safety analysis by actual arm (what the subject was exposed to), and DM is the one place both are recorded so either analysis can pick the arm it needs.

Map the reference dates and derive age

DM’s timing fields are the study’s reference points, each an ISO 8601 date. ISO 8601 is the international date standard (2014-01-02) that makes dates sortable and comparable across studies. assign_datetime() parses the raw dates and writes the compliant columns: the birth date BRTHDTC, the reference start RFSTDTC, the reference end RFENDTC, and the demographics-collection date DMDTC.

dm <- dm |>
  assign_datetime(raw_dat = dm_raw, raw_var = "BRTHDAT", tgt_var = "BRTHDTC",
                  raw_fmt = list(c("d-m-y", "dd mmm yyyy")), id_vars = oak_id_vars()) |>
  assign_datetime(raw_dat = dm_raw, raw_var = "RFSTDAT", tgt_var = "RFSTDTC",
                  raw_fmt = list(c("d-m-y", "dd mmm yyyy")), id_vars = oak_id_vars()) |>
  assign_datetime(raw_dat = dm_raw, raw_var = "RFENDAT", tgt_var = "RFENDTC",
                  raw_fmt = list(c("d-m-y", "dd mmm yyyy")), id_vars = oak_id_vars()) |>
  assign_datetime(raw_dat = dm_raw, raw_var = "DMDAT", tgt_var = "DMDTC",
                  raw_fmt = list(c("d-m-y", "dd mmm yyyy")), id_vars = oak_id_vars())

dm |>
  select(patient_number, BRTHDTC, RFSTDTC, RFENDTC) |>
  head()
# A tibble: 6 × 4
  patient_number BRTHDTC    RFSTDTC    RFENDTC   
  <chr>          <iso8601>  <iso8601>  <iso8601> 
1 1015           1950-12-26 2014-01-02 2014-07-02
2 1023           1948-07-22 2012-08-05 2012-09-02
3 1028           1942-07-11 2013-07-19 2014-01-14
4 1033           1940-03-10 2014-03-18 2014-04-14
5 1039           1955-09-05 2013-03-11 2013-05-30
6 1044           1938-11-19 2013-04-09 2013-09-30

raw_fmt tells sdtm.oak how the raw date was written (02 Jan 2014); it returns the standard 2014-01-02 form. AGE is not collected directly here — it is derived from the birth date. The SDTMIG defines AGE as the subject’s age at a study reference point; we compute it as the number of completed years between BRTHDTC and the reference start RFSTDTC, and set the unit AGEU to YEARS:

dm <- dm |>
  mutate(
    AGE  = as.integer(floor(
      as.numeric(difftime(as.Date(as.character(RFSTDTC)),
                          as.Date(as.character(BRTHDTC)), units = "days")) / 365.25)),
    AGEU = "YEARS"
  )

dm |>
  select(patient_number, BRTHDTC, RFSTDTC, AGE, AGEU) |>
  head()
# A tibble: 6 × 5
  patient_number BRTHDTC    RFSTDTC      AGE AGEU 
  <chr>          <iso8601>  <iso8601>  <int> <chr>
1 1015           1950-12-26 2014-01-02    63 YEARS
2 1023           1948-07-22 2012-08-05    64 YEARS
3 1028           1942-07-11 2013-07-19    71 YEARS
4 1033           1940-03-10 2014-03-18    74 YEARS
5 1039           1955-09-05 2013-03-11    57 YEARS
6 1044           1938-11-19 2013-04-09    74 YEARS

AGE is a plain computation, not an sdtm.oak algorithm — the package maps and derives structural SDTM variables, and an age arithmetic like this is ordinary R. (A study fixes which reference date governs the derivation — often RFSTDTC or the informed-consent date — per its SDTMIG conventions; we use RFSTDTC here.) This is the first time RFSTDTC earns its keep as an anchor — and not the last.

Add the identifiers and the study day

A conformant record needs its identifiers and its place on the study clock. STUDYID and DOMAIN are fixed; SITEID and SUBJID come from the raw fields; and USUBJID — the key every other domain joins on — is built to be unique across the whole study, conventionally STUDYID-SITEID-SUBJID. Finally derive_study_day() computes DMDY, the study day of the demographics-collection date DMDTC relative to RFSTDTC — the same function the exposure lesson used for EXSTDY/EXENDY, except here DM supplies RFSTDTC from itself.

dm <- dm |>
  left_join(dm_raw |> select(oak_id, SITE, SUBJ), by = "oak_id") |>
  mutate(
    STUDYID = "CDISCPILOT01",
    DOMAIN  = "DM",
    SITEID  = SITE,
    SUBJID  = SUBJ,
    USUBJID = paste(STUDYID, SITEID, SUBJID, sep = "-")
  )

# derive_study_day reads RFSTDTC from DM itself, so USUBJID must already be built above
dm <- dm |>
  derive_study_day(dm_domain = dm, tgdt = "DMDTC", refdt = "RFSTDTC", study_day_var = "DMDY") |>
  select(STUDYID, DOMAIN, USUBJID, SUBJID, SITEID, BRTHDTC, AGE, AGEU, SEX, RACE, ETHNIC,
         ARMCD, ARM, ACTARMCD, ACTARM, COUNTRY, RFSTDTC, RFENDTC, DMDTC, DMDY)

head(dm)
# A tibble: 6 × 20
  STUDYID    DOMAIN USUBJID SUBJID SITEID BRTHDTC   AGE AGEU  SEX   RACE  ETHNIC
  <chr>      <chr>  <chr>   <chr>  <chr>  <iso86> <int> <chr> <chr> <chr> <chr> 
1 CDISCPILO… DM     CDISCP… 1015   701    1950-1…    63 YEARS F     WHITE HISPA…
2 CDISCPILO… DM     CDISCP… 1023   701    1948-0…    64 YEARS M     WHITE HISPA…
3 CDISCPILO… DM     CDISCP… 1028   701    1942-0…    71 YEARS M     WHITE NOT H…
4 CDISCPILO… DM     CDISCP… 1033   701    1940-0…    74 YEARS M     WHITE NOT H…
5 CDISCPILO… DM     CDISCP… 1039   702    1955-0…    57 YEARS F     BLAC… NOT H…
6 CDISCPILO… DM     CDISCP… 1044   702    1938-1…    74 YEARS F     WHITE NOT H…
# ℹ 9 more variables: ARMCD <chr>, ARM <chr>, ACTARMCD <chr>, ACTARM <chr>,
#   COUNTRY <chr>, RFSTDTC <date>, RFENDTC <iso8601>, DMDTC <date>, DMDY <int>

That is a conformant DM domain: the identifiers first, then the subject descriptors, the planned and actual arms, and the reference timing. Look at DMDY: demographics are collected at screening, before the reference start, so every DMDY is negative — study day 1 is RFSTDTC itself, the day before it is day −1, and there is no day 0. That off-by-nothing convention is the single most common study-day bug, and it lives here in the domain that defines the clock. A quick structural check confirms the SDTM contract that makes DM special:

required <- c("STUDYID", "DOMAIN", "USUBJID", "SUBJID", "RFSTDTC")
all(required %in% names(dm))                 # required subject-level variables present?
[1] TRUE
length(unique(dm$DOMAIN)) == 1               # one domain code?
[1] TRUE
nrow(dm) == length(unique(dm$USUBJID))       # EXACTLY one row per subject?
[1] TRUE

Three TRUEs — and the third is the one that matters most: exactly one row per USUBJID. Unlike VS, AE, or EX, DM has no --SEQ variable, precisely because a sequence number only makes sense when a subject has several records; here each subject is a single row, so USUBJID alone is the key. In production the full check is a conformance tool (Pinnacle 21, or the pharmaverse sdtmchecks package, shown in the vital-signs lesson) that also verifies every USUBJID in every other domain exists here in DM. The columns we built are the core demographics variables the published pharmaversesdtm dm reference dataset carries (it adds several more reference-date and arm-null-reason variables you can layer on the same way). With a conformant DM in hand, every other domain can join to it and the analysis layer can build the subject-level ADSL dataset on top of it.

🟢 With an AI agent

Ask Prova “how do I add the treatment and reference-period variables — RFXSTDTC/RFXENDTC and RFICDTC — to this DM domain, and how does ADSL derive TRT01P and TRT01A from ARM and ACTARM?” — it answers grounded in this pillar’s lessons, with runnable code you can try on the example data. The runtime is the judge. Ask Prova →

Common issues

DM has more than one row for a subject — the domain’s defining rule is broken. A duplicate row usually comes from a raw extract that carried a subject twice (a re-screen, a data-entry duplicate) or from a join that fanned out. Because DM has no --SEQ to distinguish rows, two rows for one USUBJID is always an error. Check with dm[duplicated(dm$USUBJID), ]; if the raw data legitimately has a re-screening record, keep the single enrollment row the protocol specifies and drop the other — DM is one row per subject, full stop.

RFSTDTC is missing, and every study day downstream comes out NA. RFSTDTC is the anchor derive_study_day() reads for every domain — a blank one here silently propagates to VSDY, AEDY, EXDY, and every analysis date. It is missing for a real reason (a screen-failure subject who was never dosed has no reference start), so confirm the subject genuinely has no reference start rather than patching a value; a legitimately null RFSTDTC is conformant, and the downstream code should handle the NA deliberately, not be fed a fabricated date.

You mix up ARM and ACTARM, or populate only one. ARM is planned (randomized), ACTARM is actual (received); an efficacy analysis reads ARM, a safety analysis reads ACTARM. Populating ACTARM from the randomization data — or leaving it blank and assuming it equals ARM — hides exactly the mis-dosed subjects the safety review needs to find. Map them from separate raw fields, and when they differ (subject 1044 here), trust the difference rather than “correcting” it.

Frequently asked questions

The SDTM DM (demographics) domain is the CDISC SDTM dataset that holds one subject-level record per trial participant — the Special-Purpose class. Each row carries the unique subject identifier USUBJID, the subject descriptors (AGE, AGEU, SEX, RACE, ETHNIC), the planned and actual treatment arms (ARM/ARMCD, ACTARM/ACTARMCD), and the reference dates (RFSTDTC, RFENDTC, BRTHDTC). It is the subject spine of the whole study: every other domain joins to it by USUBJID, and it is the source of the subject-level variables in ADSL.

RFSTDTC is the subject reference start date on the DM domain — typically the date of first study treatment or randomization. It is the origin of the study clock: every domain’s study-day variable (VSDY, AEDY, EXDY, DMDY) is derived as the number of days from RFSTDTC to the observation date, where study day 1 is RFSTDTC itself and the day before it is day −1 — there is no day 0. Because it anchors every study day, a missing or wrong RFSTDTC propagates errors into every domain downstream, which is why it lives on the single subject-level DM record.

ARM (with its short code ARMCD) is the planned treatment arm — the arm the subject was randomized to. ACTARM (ACTARMCD) is the actual arm — the treatment the subject actually received. They are identical for most subjects but differ when a subject is dosed or enrolled differently than planned (a randomization or dosing error). Both exist because an efficacy (intention-to-treat) analysis is run by planned arm while a safety analysis is run by actual arm, and DM is the one place both are recorded. Both are study-defined values from the Trial Arms definition, copied verbatim with assign_no_ct().

The three general observation classes each record many rows per subject keyed by a topic variable: Findings (a measurement, keyed by --TESTCD), Events (something that happened, keyed by --TERM), Interventions (a treatment given, keyed by --TRT). The Special-Purpose class — which DM belongs to — has no topic variable and holds subject-level or trial-reference information; DM specifically has exactly one row per subject and no --SEQ sequence number. It is not one observation among many, but the anchor the observations attach to.

Use assign_ct() for the controlled-terminology descriptors recoded through a codelist (SEX via C66731, RACE via C74457, ETHNIC via C66790); assign_no_ct() for the study-defined values copied as-is (the planned and actual arms ARM/ARMCD/ACTARM/ACTARMCD, and COUNTRY); assign_datetime() for the ISO 8601 reference dates (RFSTDTC, RFENDTC, BRTHDTC, DMDTC); and derive_study_day() for DMDY. AGE is a plain R computation from BRTHDTC to RFSTDTC, and generate_oak_id_vars() first stamps the lineage key every map joins back on. DM has no derive_seq() step — a Special-Purpose one-row-per-subject domain carries no --SEQ. The algorithms vignette documents the mapping algorithms, and the package reference index lists every function.

Test your understanding

Suppose the raw extract gains a screen-failure subject — enrolled and assessed, but never randomized or dosed. By CDISC convention such a subject has ARMCD = "SCRNFAIL" and ARM = "Screen Failure", no actual arm, and no reference start date. Sketch what changes: what do ARM, ACTARM, and RFSTDTC hold, and what happens to that subject’s DMDY and to the study days in every other domain?

A screen failure was never treated, so there is no actual treatment and no reference start. Think about what derive_study_day() returns when its refdt (RFSTDTC) is missing — and remember that a legitimately null RFSTDTC is conformant, not a bug to patch.

The screen-failure row still exists in DM (DM has one row per enrolled subject, treated or not), but:

# for the screen-failure subject:
#   ARMCD   = "SCRNFAIL"      ARM    = "Screen Failure"    (planned "arm" = the not-assigned category)
#   ACTARMCD = ""             ACTARM = ""                  (never treated -> actual arm is blank)
#   RFSTDTC  = NA                                          (no reference start -> no study clock)

With RFSTDTC missing, derive_study_day() returns NA for that subject’s DMDY — and, crucially, for every study day in every other domain, because they all anchor on this subject’s RFSTDTC. That is correct behaviour: a subject with no treatment has no study clock, so their study days are genuinely undefined. You never invent a reference date to make the NA go away; the downstream analysis handles the null deliberately. This is the clearest demonstration of why DM is the spine — one missing date here changes what every other domain can compute for that subject.

A. Because sdtm.oak cannot generate a sequence number for demographics B. Because DM is a Special-Purpose domain with exactly one row per subject, so USUBJID alone is the key C. Because the DM sequence number is stored in the ADSL dataset instead

B. A --SEQ variable exists to distinguish multiple records for one subject within a domain (a subject has many AE, VS, or EX rows, so AESEQ/VSSEQ/EXSEQ number them). DM is a Special-Purpose domain with one row per subject, so there is nothing to sequence — USUBJID by itself uniquely identifies the record. A is false (the absence is by design, not a tool limitation), and C is false — ADSL is a downstream analysis dataset, also one row per subject, and likewise has no such sequence.

Conclusion

Mapping DM is the same one-field-at-a-time loop as any SDTM domain, applied to the Special-Purpose class: fill the right SDTM column from the right raw field with the right algorithm. assign_ct() recodes the sex, race, and ethnicity through controlled terminology; assign_no_ct() copies the study-defined planned and actual arms; assign_datetime() builds the ISO 8601 reference dates; AGE is a plain derivation from the birth date; and derive_study_day() places the demographics date on the clock. But DM is not just another domain — it is the subject spine: one row per subject, keyed by USUBJID, anchored on RFSTDTC, with planned and actual treatment side by side. Every other domain joins to it and clocks against it, so getting DM right is the precondition for every dataset downstream. With a conformant DM in hand, the next step is the analysis layer, where these subject-level columns become the backbone of the ADSL dataset built with admiral.

Note

This lesson is reproducible: every result on this page was produced by the code shown — copy any block and run it to reproduce them. The runtime is the judge.

Reuse

Citation

BibTeX citation:
@online{2026,
  author = {},
  title = {SDTM {DM} {Domain} in {R} with Sdtm.oak: {Build} the
    {Demographics} {Special-Purpose} {Class}},
  date = {2026-07-01},
  url = {https://www.datanovia.com/learn/pharma-clinical/02-sdtm-programming/sdtm-dm-demographics-domain},
  langid = {en}
}
For attribution, please cite this work as:
“SDTM DM Domain in R with Sdtm.oak: Build the Demographics Special-Purpose Class.” 2026. July 1. https://www.datanovia.com/learn/pharma-clinical/02-sdtm-programming/sdtm-dm-demographics-domain.