SDTM programming in R
Lesson 3 — Clinical Programming with R
Learning objectives
- Describe SDTM structure: classes, domains, variable roles
- Map raw EDC data to an SDTM domain
- Derive
--SEQ,--DY,--STDTCand 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")
)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 ~ ""
)
}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 charactersQLABEL≤ 40 charactersQVAL≤ 200 characters — longer values must be split acrossQNAM1,QNAM2IDVAR/IDVARVALmust resolve to exactly one parent record- No
QVALmay 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-28Show 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 RatePoints that matter:
VSDY <= 0for 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 <= 0is the conservative reading; if the SAP requires same-day-pre-dose records, you needVSTPT/VSDTCtimes.VSORRESis character. It holds the result as collected, and some vital signs are recorded as text (“NOT DONE”).VSSTRESNis the numeric version.- The
VSSEQsort includesVSTESTCDso it is deterministic when several measurements share a date and visit. VSSTRESC/VSSTRESUare 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 inVSSTRESNandVSORRESUretains “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. Thelubridate::ymd()parse check catches it. Both checks are needed. - Reporting
n_partialrather 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 --SEQmust 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:
--DYisdate - ref + 1when 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