SDTM programming in R

Lesson 3 — Clinical Programming with R

Lesson 3 of 12 Intermediate to advanced ~110 min

Learning objectives

  • Describe SDTM structure: classes, domains, variable roles
  • Map raw EDC data to an SDTM domain
  • Derive --SEQ, --DY, --STDTC and other standard variables
  • Handle ISO 8601 dates, including partial and interval forms
  • Build SUPPQUAL and RELREC correctly
  • Run conformance checks before delivering

What SDTM is

The Study Data Tabulation Model organises collected data into standard domains with standard variable names. It is a tabulation model — it represents what was collected, as collected. Derivation belongs in ADaM.

That distinction is the one people get wrong. If you find yourself computing a change from baseline in SDTM, you are in the wrong layer.

Observation classes

Class Contains Domains
Interventions What was given to the subject CM, EX, EC, SU, PR
Events What happened to the subject AE, DS, MH, CE, DV
Findings What was measured LB, VS, EG, QS, PE, IE
Findings About Findings about an event or intervention FA, SR
Special purpose Not in a class DM, CO, SE, SV
Trial design Study structure, not subject data TA, TE, TS, TV, TI
Relationship Links between records RELREC, SUPP–

Each class has a required structure. Findings domains are one row per measurement; Events domains are one row per event; Interventions are one row per administration or period.

Variable roles

Role Purpose Examples
Identifier Identifies the record STUDYID, USUBJID, --SEQ, DOMAIN
Topic What the observation is about AETERM, LBTESTCD, CMTRT
Qualifier Describes the topic AESEV, LBSTRESN, LBSTRESU
Timing When it happened --DTC, --STDTC, --ENDTC, VISIT, --DY
Rule Trial design logic TAETORD, TABRANCH

Qualifiers subdivide further into grouping (--CAT, --SCAT), result (--ORRES, --STRESC, --STRESN), synonym (--DECOD, --MODIFY), record (--REASND, --BLFL) and variable (--TESTCD, --TEST) qualifiers. The distinction matters when writing define.xml.

Core designations

Core Meaning
Req (Required) Must be present, must be populated
Exp (Expected) Must be present, may be null
Perm (Permissible) Include only if collected

A common error is including a permissible variable, entirely null, “for completeness”. Conformance checks flag it.

A worked mapping: AE

Raw EDC export:

raw_ae <- tibble::tribble(
  ~SUBJECT, ~SITE, ~AETERM_RAW,       ~AESTDAT,      ~AEENDAT,      ~SEVERITY, ~SERIOUS, ~REL,       ~OUTCOME,
  "0042",   "001", "Headache",        "15/03/2026",  "17/03/2026",  "Mild",    "No",     "Unlikely", "Recovered",
  "0042",   "001", "Nausea",          "20/03/2026",  "",            "Moderate","No",     "Possible", "Ongoing",
  "0107",   "002", "Elevated ALT",    "02/04/2026",  "28/04/2026",  "Severe",  "Yes",    "Probable", "Recovered"
)

Mapping to SDTM AE:

library(dplyr); library(lubridate); library(stringr)

STUDYID <- "ABC-101"

ae <- raw_ae |>
  transmute(
    STUDYID  = STUDYID,
    DOMAIN   = "AE",
    USUBJID  = paste(STUDYID, SITE, SUBJECT, sep = "-"),

    # Topic
    AETERM   = str_squish(AETERM_RAW),

    # Coded terms come from the MedDRA-coded file, joined later
    AEDECOD  = NA_character_,
    AEBODSYS = NA_character_,

    # Qualifiers, mapped to controlled terminology
    AESEV = case_when(
      str_to_upper(SEVERITY) == "MILD"     ~ "MILD",
      str_to_upper(SEVERITY) == "MODERATE" ~ "MODERATE",
      str_to_upper(SEVERITY) == "SEVERE"   ~ "SEVERE",
      .default = NA_character_
    ),
    AESER = case_when(
      str_to_upper(SERIOUS) == "YES" ~ "Y",
      str_to_upper(SERIOUS) == "NO"  ~ "N",
      .default = NA_character_
    ),
    AEREL = case_when(
      str_to_upper(REL) %in% c("NOT RELATED", "UNLIKELY") ~ "NOT RELATED",
      str_to_upper(REL) == "POSSIBLE"                     ~ "POSSIBLY RELATED",
      str_to_upper(REL) == "PROBABLE"                     ~ "PROBABLY RELATED",
      str_to_upper(REL) == "DEFINITE"                     ~ "RELATED",
      .default = NA_character_
    ),
    AEOUT = case_when(
      str_to_upper(OUTCOME) == "RECOVERED" ~ "RECOVERED/RESOLVED",
      str_to_upper(OUTCOME) == "ONGOING"   ~ "NOT RECOVERED/NOT RESOLVED",
      str_to_upper(OUTCOME) == "FATAL"     ~ "FATAL",
      .default = NA_character_
    ),

    # Timing: raw dd/mm/yyyy -> ISO 8601
    AESTDTC = to_iso8601(AESTDAT, format = "dmy"),
    AEENDTC = to_iso8601(AEENDAT, format = "dmy")
  )
ImportantUnmapped values must fail, not become NA
AESEV = case_when(
  ...,
  .default = NA_character_    # DANGEROUS
)

A severity of "Grade 3" that the mapping does not anticipate silently becomes missing. Detect it:

check_mapping <- function(raw, mapped, var) {
  bad <- unique(raw[is.na(mapped) & !is.na(raw) & raw != ""])
  if (length(bad) > 0) {
    cli::cli_abort(c(
      "Unmapped value{?s} for {.var {var}}.",
      "x" = "{.val {bad}}",
      "i" = "Update the mapping or raise a data query."
    ))
  }
}

check_mapping(raw_ae$SEVERITY, ae$AESEV, "AESEV")

Run this for every controlled-terminology mapping. It is the single highest-value check in SDTM programming.

ISO 8601 dates

SDTM --DTC variables are ISO 8601 character strings, not dates. Partial dates are represented by omitting components:

Value Meaning
2026-03-15 Complete date
2026-03 Month and year known
2026 Year only
2026---15 Year and day known, month unknown
2026-03-15T14:30 Date and time
2026-03-15T14:30:00 With seconds
--03-15 Month and day, year unknown
"" Nothing known
to_iso8601 <- function(x, format = c("dmy", "mdy", "ymd")) {
  format <- match.arg(format)
  x <- trimws(as.character(x))

  out <- rep(NA_character_, length(x))
  blank <- is.na(x) | x == ""
  out[blank] <- ""

  parsed <- switch(format,
    dmy = suppressWarnings(lubridate::dmy(x[!blank])),
    mdy = suppressWarnings(lubridate::mdy(x[!blank])),
    ymd = suppressWarnings(lubridate::ymd(x[!blank]))
  )
  out[!blank] <- format(parsed, "%Y-%m-%d")
  out[is.na(out)] <- ""
  out
}

Handling partial raw dates, where the day is recorded as "UNK":

raw_to_dtc <- function(day, month, year) {
  yr <- ifelse(is.na(year)  | year  %in% c("", "UNK", "UN"), NA, sprintf("%04d", as.integer(year)))
  mo <- ifelse(is.na(month) | month %in% c("", "UNK", "UN"), NA, sprintf("%02d", month_number(month)))
  dy <- ifelse(is.na(day)   | day   %in% c("", "UNK", "UN"), NA, sprintf("%02d", as.integer(day)))

  dplyr::case_when(
    !is.na(yr) & !is.na(mo) & !is.na(dy) ~ paste(yr, mo, dy, sep = "-"),
    !is.na(yr) & !is.na(mo)              ~ paste(yr, mo, sep = "-"),
    !is.na(yr) & !is.na(dy)              ~ paste0(yr, "---", dy),
    !is.na(yr)                           ~ yr,
    .default                             ~ ""
  )
}
WarningNever impute in SDTM

SDTM records what was collected. If the day is unknown, AESTDTC is "2026-03" and that is correct and complete. Imputation to "2026-03-01" is a derivation and belongs in ADaM (ASTDT), with an imputation flag (ASTDTF). Imputing in SDTM destroys the information that the day was unknown and is a conformance finding.

Standard derivations

--SEQ

A sequence number, unique within USUBJID and domain:

ae <- ae |>
  arrange(USUBJID, AESTDTC, AETERM) |>
  mutate(AESEQ = row_number(), .by = USUBJID)

The sort must be deterministic. If two AEs share a start date and term, the order is arbitrary and AESEQ will differ between runs — which breaks RELREC and SUPPQUAL links. Add enough keys:

ae <- ae |>
  arrange(USUBJID, AESTDTC, AEENDTC, AETERM, AESPID) |>
  mutate(AESEQ = row_number(), .by = USUBJID)

stopifnot(!anyDuplicated(ae[, c("USUBJID", "AESEQ")]))

Where possible, derive --SEQ from a stable EDC record identifier rather than from a sort, so it is reproducible across data extracts.

--DY

Study day relative to RFSTDTC, with no day zero:

compute_dy <- function(dtc, rfstdtc) {
  d   <- suppressWarnings(lubridate::ymd(substr(dtc, 1, 10)))
  ref <- suppressWarnings(lubridate::ymd(substr(rfstdtc, 1, 10)))

  dplyr::case_when(
    is.na(d) | is.na(ref) ~ NA_integer_,
    d >= ref              ~ as.integer(d - ref) + 1L,
    .default              ~ as.integer(d - ref)
  )
}

ae <- ae |>
  left_join(select(dm, USUBJID, RFSTDTC), by = "USUBJID") |>
  mutate(
    AESTDY = compute_dy(AESTDTC, RFSTDTC),
    AEENDY = compute_dy(AEENDTC, RFSTDTC)
  ) |>
  select(-RFSTDTC)

A partial --DTC gives NA for --DY, which is correct — you cannot compute a study day from an unknown date.

--BLFL

Baseline flag on findings domains:

lb <- lb |>
  mutate(
    lb_date = lubridate::ymd(substr(LBDTC, 1, 10)),
    ref     = lubridate::ymd(substr(RFSTDTC, 1, 10))
  ) |>
  arrange(USUBJID, LBTESTCD, desc(lb_date), LBSEQ) |>
  mutate(
    LBBLFL = if_else(
      row_number() == 1 & !is.na(lb_date) & lb_date <= ref,
      "Y", NA_character_
    ),
    .by = c(USUBJID, LBTESTCD)
  ) |>
  select(-lb_date, -ref)

The convention here — last non-missing value on or before first dose — is the common one, but the SAP governs. Sort descending by date so row_number() == 1 is the latest qualifying record.

EPOCH and --TPT

ae <- ae |>
  left_join(select(dm, USUBJID, RFXSTDTC, RFXENDTC), by = "USUBJID") |>
  mutate(
    ae_dt = lubridate::ymd(substr(AESTDTC, 1, 10)),
    EPOCH = case_when(
      is.na(ae_dt)                                    ~ NA_character_,
      ae_dt <  lubridate::ymd(substr(RFXSTDTC, 1, 10))~ "SCREENING",
      ae_dt <= lubridate::ymd(substr(RFXENDTC, 1, 10))~ "TREATMENT",
      .default                                         ~ "FOLLOW-UP"
    )
  ) |>
  select(-ae_dt, -RFXSTDTC, -RFXENDTC)

SUPPQUAL

Non-standard variables go into a supplemental qualifier dataset rather than being added to the parent domain.

make_supp <- function(parent, domain, idvar, qnam_map) {
  parent |>
    select(STUDYID, USUBJID, all_of(idvar), all_of(names(qnam_map))) |>
    tidyr::pivot_longer(
      cols      = all_of(names(qnam_map)),
      names_to  = "QNAM",
      values_to = "QVAL",
      values_transform = as.character
    ) |>
    filter(!is.na(QVAL), QVAL != "") |>
    mutate(
      RDOMAIN = domain,
      IDVAR   = idvar,
      IDVARVAL = as.character(.data[[idvar]]),
      QLABEL  = unname(qnam_map[QNAM]),
      QORIG   = "CRF",
      QEVAL   = ""
    ) |>
    select(STUDYID, RDOMAIN, USUBJID, IDVAR, IDVARVAL,
           QNAM, QLABEL, QVAL, QORIG, QEVAL) |>
    arrange(USUBJID, IDVARVAL, QNAM)
}

suppae <- make_supp(
  ae, domain = "AE", idvar = "AESEQ",
  qnam_map = c(
    AETRTEM = "Treatment Emergent Flag",
    AEACNOTH = "Other Action Taken"
  )
)

suppae
#> # A tibble: 4 x 10
#>   STUDYID RDOMAIN USUBJID          IDVAR IDVARVAL QNAM    QLABEL                    QVAL  QORIG QEVAL
#>   <chr>   <chr>   <chr>            <chr> <chr>    <chr>   <chr>                     <chr> <chr> <chr>
#> 1 ABC-101 AE      ABC-101-001-0042 AESEQ 1        AETRTEM Treatment Emergent Flag   Y     CRF   ""

Rules that conformance checks enforce:

  • QNAM ≤ 8 characters, valid variable-name characters
  • QLABEL ≤ 40 characters
  • QVAL ≤ 200 characters — longer values must be split across QNAM1, QNAM2
  • IDVAR/IDVARVAL must resolve to exactly one parent record
  • No QVAL may be missing

RELREC

Links records between domains — for example, an AE that caused a dose reduction recorded in EX.

relrec <- bind_rows(
  # One row per related record, sharing a RELID
  ae_ex_links |>
    transmute(STUDYID, RDOMAIN = "AE", USUBJID,
              IDVAR = "AESEQ", IDVARVAL = as.character(AESEQ),
              RELTYPE = "", RELID),
  ae_ex_links |>
    transmute(STUDYID, RDOMAIN = "EX", USUBJID,
              IDVAR = "EXSEQ", IDVARVAL = as.character(EXSEQ),
              RELTYPE = "", RELID)
) |>
  arrange(RELID, RDOMAIN, USUBJID)

Verify every link resolves:

check_relrec <- function(relrec, domains) {
  purrr::pwalk(relrec, function(RDOMAIN, USUBJID, IDVAR, IDVARVAL, ...) {
    parent <- domains[[RDOMAIN]]
    hit <- parent[[IDVAR]] == as.numeric(IDVARVAL) & parent$USUBJID == USUBJID
    if (sum(hit, na.rm = TRUE) != 1) {
      cli::cli_abort("RELREC does not resolve: {RDOMAIN} {IDVAR}={IDVARVAL} for {USUBJID}")
    }
  })
}

Conformance

Before delivering, run the checks:

  • Pinnacle 21 Community — the de facto standard, free, catches most findings
  • CDISC CORE — the open-source rules engine, increasingly used
  • Your own checks for study-specific rules

Basic checks worth having in code, so they fail fast:

sdtm_checks <- function(dat, domain) {
  probs <- character()

  # Required variables
  req <- c("STUDYID", "DOMAIN", "USUBJID")
  if (length(setdiff(req, names(dat))) > 0) {
    probs <- c(probs, paste("Missing required:", 
                            paste(setdiff(req, names(dat)), collapse = ", ")))
  }

  # DOMAIN must equal the domain code, on every row
  if ("DOMAIN" %in% names(dat) && !all(dat$DOMAIN == domain)) {
    probs <- c(probs, "DOMAIN does not match the domain code on all records")
  }

  # --SEQ uniqueness
  seqvar <- paste0(domain, "SEQ")
  if (seqvar %in% names(dat) && anyDuplicated(dat[, c("USUBJID", seqvar)])) {
    probs <- c(probs, paste(seqvar, "is not unique within USUBJID"))
  }

  # ISO 8601 format on every --DTC
  dtc_vars <- grep("DTC$", names(dat), value = TRUE)
  for (v in dtc_vars) {
    bad <- dat[[v]][!is.na(dat[[v]]) & dat[[v]] != "" &
                    !grepl("^\\d{4}(-\\d{2}(-\\d{2}(T\\d{2}(:\\d{2}(:\\d{2})?)?)?)?)?$|^\\d{4}---\\d{2}$",
                           dat[[v]])]
    if (length(bad) > 0) {
      probs <- c(probs, sprintf("%s has %d non-ISO8601 value(s): %s",
                                v, length(bad), paste(head(unique(bad), 3), collapse = ", ")))
    }
  }

  # Character variables must not exceed 200 bytes
  wide <- names(dat)[vapply(dat, \(x) is.character(x) &&
                              any(nchar(x, type = "bytes") > 200, na.rm = TRUE),
                            logical(1))]
  if (length(wide) > 0) {
    probs <- c(probs, paste("Exceeds 200 bytes:", paste(wide, collapse = ", ")))
  }

  if (length(probs) > 0) {
    cli::cli_warn(c("{domain} conformance issues:",
                    stats::setNames(probs, rep("x", length(probs)))))
  } else {
    cli::cli_alert_success("{domain}: basic conformance checks passed")
  }
  invisible(probs)
}

Should you build SDTM in R?

Honestly: less often than ADaM.

SDTM mapping is dominated by study-specific EDC-to-standard logic that does not generalise, and most CROs have mature SAS mapping libraries. The pharmaverse has no admiral equivalent for SDTM — sdtm.oak is the emerging attempt but is much younger.

R is a strong choice for SDTM when:

  • The study is R-first end to end
  • The EDC export is already close to SDTM
  • You want the mapping under version control with tests
  • You are building a reusable mapping framework, not one study

SAS remains a reasonable choice when an existing validated mapping library covers the therapeutic area. There is no virtue in rewriting working, validated mapping code.

Common mistakes

Mistake Consequence Fix
Deriving in SDTM Wrong layer; conformance findings Derive in ADaM
Imputing partial dates in SDTM Information destroyed Keep partial; impute in ADaM
Non-deterministic --SEQ sort Links break between runs Sort on enough keys; assert uniqueness
Unmapped CT values → NA Silent data loss Check every mapping explicitly
Permissible variables all null Conformance finding Omit if not collected
QVAL over 200 characters Non-conformant SUPPQUAL Split across QNAM1/QNAM2
RELREC that does not resolve Conformance finding Verify every link programmatically
Skipping Pinnacle 21 until the end Late, expensive rework Run it on every increment

Exercise 3.1 — Map a vital signs CRF to SDTM VS

Map this raw export to SDTM VS, including VSSEQ, VSDY, VSBLFL, and ISO dates. Systolic and diastolic BP are in separate columns and must become separate records.

raw_vs <- tibble::tribble(
  ~SUBJ,  ~SITE, ~VISIT_NAME, ~VISIT_NO, ~VSDATE,      ~SYSBP, ~DIABP, ~PULSE, ~TEMP,
  "0042", "001", "Screening",  1,        "10/03/2026",  120,    80,     72,     36.8,
  "0042", "001", "Week 4",     3,        "12/04/2026",  118,    78,     70,     36.6,
  "0107", "002", "Screening",  1,        "25/03/2026",  145,    92,     88,     37.1
)
# First dose dates: 0042 -> 2026-03-15, 0107 -> 2026-03-28
Show solution
library(dplyr); library(tidyr); library(lubridate)

STUDYID <- "ABC-101"

first_dose <- tibble::tribble(
  ~USUBJID,           ~RFSTDTC,
  "ABC-101-001-0042", "2026-03-15",
  "ABC-101-002-0107", "2026-03-28"
)

vs <- raw_vs |>
  mutate(
    USUBJID = paste(STUDYID, SITE, SUBJ, sep = "-"),
    VSDTC   = format(dmy(VSDATE), "%Y-%m-%d")
  ) |>
  # Wide -> long: one record per measurement
  pivot_longer(
    cols      = c(SYSBP, DIABP, PULSE, TEMP),
    names_to  = "raw_test",
    values_to = "VSORRES",
    values_transform = as.character
  ) |>
  filter(!is.na(VSORRES)) |>
  mutate(
    STUDYID  = STUDYID,
    DOMAIN   = "VS",

    # Controlled terminology for the test
    VSTESTCD = recode(raw_test,
                      SYSBP = "SYSBP", DIABP = "DIABP",
                      PULSE = "PULSE", TEMP  = "TEMP"),
    VSTEST   = recode(raw_test,
                      SYSBP = "Systolic Blood Pressure",
                      DIABP = "Diastolic Blood Pressure",
                      PULSE = "Pulse Rate",
                      TEMP  = "Temperature"),
    VSCAT    = "VITAL SIGNS",

    # Original result and units
    VSORRESU = recode(raw_test,
                      SYSBP = "mmHg", DIABP = "mmHg",
                      PULSE = "beats/min", TEMP = "C"),

    # Standardised result — same units here, so a direct copy
    VSSTRESC = VSORRES,
    VSSTRESN = suppressWarnings(as.numeric(VSORRES)),
    VSSTRESU = VSORRESU,

    VISIT    = VISIT_NAME,
    VISITNUM = VISIT_NO
  ) |>
  # Study day
  left_join(first_dose, by = "USUBJID") |>
  mutate(
    VSDY = {
      d   <- ymd(VSDTC)
      ref <- ymd(RFSTDTC)
      case_when(
        is.na(d) | is.na(ref) ~ NA_integer_,
        d >= ref              ~ as.integer(d - ref) + 1L,
        .default              ~ as.integer(d - ref)
      )
    }
  ) |>
  # Baseline flag: last record on or before first dose, per test
  arrange(USUBJID, VSTESTCD, desc(ymd(VSDTC)), VISITNUM) |>
  mutate(
    VSBLFL = if_else(row_number() == 1 & !is.na(VSDY) & VSDY <= 0, "Y", NA_character_),
    .by = c(USUBJID, VSTESTCD)
  ) |>
  # VSSEQ — deterministic sort
  arrange(USUBJID, VSDTC, VISITNUM, VSTESTCD) |>
  mutate(VSSEQ = row_number(), .by = USUBJID) |>
  select(STUDYID, DOMAIN, USUBJID, VSSEQ, VSTESTCD, VSTEST, VSCAT,
         VSORRES, VSORRESU, VSSTRESC, VSSTRESN, VSSTRESU,
         VISITNUM, VISIT, VSDTC, VSDY, VSBLFL)

stopifnot(!anyDuplicated(vs[, c("USUBJID", "VSSEQ")]))

vs
#> # A tibble: 12 x 17
#>   STUDYID DOMAIN USUBJID          VSSEQ VSTESTCD VSTEST                    ...
#>   <chr>   <chr>  <chr>            <int> <chr>    <chr>
#> 1 ABC-101 VS     ABC-101-001-0042     1 DIABP    Diastolic Blood Pressure
#> 2 ABC-101 VS     ABC-101-001-0042     2 PULSE    Pulse Rate

Points that matter:

  • VSDY <= 0 for the baseline flag, not < 0 — there is no day zero, so day −1 and earlier are pre-dose, and a same-day pre-dose measurement would need the time to distinguish. With date-only data, VSDY <= 0 is the conservative reading; if the SAP requires same-day-pre-dose records, you need VSTPT/VSDTC times.
  • VSORRES is character. It holds the result as collected, and some vital signs are recorded as text (“NOT DONE”). VSSTRESN is the numeric version.
  • The VSSEQ sort includes VSTESTCD so it is deterministic when several measurements share a date and visit.
  • VSSTRESC/VSSTRESU are a direct copy here because no unit conversion is needed. Where the CRF collects temperature in Fahrenheit at some sites, the conversion to Celsius happens in VSSTRESN and VSORRESU retains “F”.

Exercise 3.2 — Handle partial dates end to end

Write a function that takes raw day/month/year components (any of which may be “UNK”) and returns a valid --DTC. Then write the checks that verify every --DTC in a dataset is well-formed ISO 8601.

Show solution
library(dplyr); library(stringr)

MONTH_MAP <- c(JAN = 1, FEB = 2, MAR = 3, APR = 4, MAY = 5, JUN = 6,
               JUL = 7, AUG = 8, SEP = 9, OCT = 10, NOV = 11, DEC = 12)

is_unknown <- function(x) {
  x <- str_trim(as.character(x))
  is.na(x) | x == "" | str_to_upper(x) %in% c("UNK", "UN", "UNKNOWN", "NK", "--")
}

parse_month <- function(x) {
  x <- str_to_upper(str_trim(as.character(x)))
  num <- suppressWarnings(as.integer(x))
  out <- ifelse(!is.na(num), num, unname(MONTH_MAP[x]))
  ifelse(!is.na(out) & out >= 1 & out <= 12, out, NA_integer_)
}

build_dtc <- function(day, month, year, strict = TRUE) {
  yr <- ifelse(is_unknown(year), NA_integer_,
               suppressWarnings(as.integer(str_trim(as.character(year)))))
  mo <- ifelse(is_unknown(month), NA_integer_, parse_month(month))
  dy <- ifelse(is_unknown(day), NA_integer_,
               suppressWarnings(as.integer(str_trim(as.character(day)))))

  # Validate ranges before assembling
  if (strict) {
    bad_year  <- !is.na(yr) & (yr < 1900 | yr > 2100)
    bad_day   <- !is.na(dy) & (dy < 1 | dy > 31)
    if (any(bad_year)) cli::cli_abort("Implausible year: {.val {unique(yr[bad_year])}}")
    if (any(bad_day))  cli::cli_abort("Invalid day: {.val {unique(dy[bad_day])}}")

    # Day must exist in the month
    both <- !is.na(yr) & !is.na(mo) & !is.na(dy)
    if (any(both)) {
      max_day <- lubridate::days_in_month(
        as.Date(sprintf("%04d-%02d-01", yr[both], mo[both])))
      invalid <- dy[both] > max_day
      if (any(invalid)) {
        cli::cli_abort(c(
          "Day does not exist in the given month.",
          "x" = "e.g. {sprintf('%04d-%02d-%02d', yr[both][invalid][1],
                               mo[both][invalid][1], dy[both][invalid][1])}"
        ))
      }
    }
  }

  case_when(
    !is.na(yr) & !is.na(mo) & !is.na(dy) ~ sprintf("%04d-%02d-%02d", yr, mo, dy),
    !is.na(yr) & !is.na(mo)              ~ sprintf("%04d-%02d",      yr, mo),
    !is.na(yr) & !is.na(dy)              ~ sprintf("%04d---%02d",    yr, dy),
    !is.na(yr)                           ~ sprintf("%04d",           yr),
    .default                             ~ ""
  )
}

build_dtc(c(15, "UNK", "UNK", 20), c("MAR", "MAR", "UNK", "UNK"),
          c(2026, 2026, 2026, 2026))
#> [1] "2026-03-15" "2026-03"    "2026"       "2026---20"

The validation function:

ISO8601_PATTERN <- paste0(
  "^(",
    "\\d{4}",                                  # YYYY
    "(-\\d{2}",                                # -MM
      "(-\\d{2}",                              # -DD
        "(T\\d{2}",                            # THH
          "(:\\d{2}",                          # :MM
            "(:\\d{2}(\\.\\d+)?)?",            # :SS(.sss)
          ")?",
        ")?",
      ")?",
    ")?",
  "|",
    "\\d{4}---\\d{2}",                         # YYYY---DD
  "|",
    "--\\d{2}-\\d{2}",                         # --MM-DD
  ")$"
)

check_dtc <- function(dat, domain = NULL) {
  dtc_vars <- grep("DTC$", names(dat), value = TRUE)
  if (length(dtc_vars) == 0) return(invisible(NULL))

  issues <- purrr::map(dtc_vars, function(v) {
    x <- dat[[v]]
    populated <- !is.na(x) & x != ""

    malformed <- populated & !grepl(ISO8601_PATTERN, x)

    # Semantic check: does the complete-date form parse?
    complete <- populated & grepl("^\\d{4}-\\d{2}-\\d{2}", x)
    unparseable <- complete &
      is.na(suppressWarnings(lubridate::ymd(substr(x, 1, 10))))

    tibble::tibble(
      variable  = v,
      n_total   = length(x),
      n_missing = sum(!populated),
      n_partial = sum(populated & nchar(x) < 10),
      n_bad     = sum(malformed | unparseable),
      examples  = paste(head(unique(x[malformed | unparseable]), 3),
                        collapse = " | ")
    )
  }) |> purrr::list_rbind()

  bad <- dplyr::filter(issues, n_bad > 0)
  if (nrow(bad) > 0) {
    cli::cli_abort(c(
      "{domain %||% 'Dataset'}: invalid ISO 8601 date value{?s}.",
      stats::setNames(
        sprintf("%s: %d invalid (%s)", bad$variable, bad$n_bad, bad$examples),
        rep("x", nrow(bad))
      )
    ))
  }

  cli::cli_alert_success(
    "{domain %||% 'Dataset'}: {nrow(issues)} date variable{?s} conform to ISO 8601 ({sum(issues$n_partial)} partial)"
  )
  invisible(issues)
}

check_dtc(ae, "AE")
#> ✔ AE: 2 date variables conform to ISO 8601 (14 partial)

Two things worth calling out:

  • The pattern alone is insufficient. "2026-02-30" matches the regex perfectly but is not a real date. The lubridate::ymd() parse check catches it. Both checks are needed.
  • Reporting n_partial rather than treating partials as errors. Partial dates are valid and expected in SDTM; the count is useful information for the ADaM programmer, who now knows how much imputation to expect.

Recap

  • SDTM tabulates what was collected; derivation belongs in ADaM
  • Never impute partial dates in SDTM — keep the partial --DTC
  • --SEQ must come from a deterministic sort or a stable EDC identifier
  • Check every controlled-terminology mapping for unmapped values, and fail
  • Study day has no zero: --DY is date - ref + 1 when on or after the reference
  • SUPPQUAL keys must resolve to exactly one parent record; QVAL ≤ 200 characters
  • Run Pinnacle 21 or CORE on every increment, not at the end

Next: ADaM programming with admiral.

Back to top