The problem

A Define-XML document is a promise about the data: these datasets exist, these variables are in them, this variable is at most 20 characters, this one only ever holds F or M. The document is usually produced from a spreadsheet by a tool that never opens the datasets, so the promise quietly stops being true. define21 builds the document and checks the promise.

1. The specification

A specification is eight plain data frames. Only datasets and variables are required. Here is the example shipped with the package, read from CSV - a spreadsheet export works exactly the same way.

p <- system.file("extdata", package = "define21")
meta <- function(f) utils::read.csv(file.path(p, f), colClasses = "character", na.strings = "")

spec <- define_spec(
  datasets           = meta("datasets.csv"),
  variables          = meta("variables.csv"),
  codelists          = meta("codelists.csv"),
  methods            = meta("methods.csv"),
  comments           = meta("comments.csv"),
  value_levels       = meta("value_levels.csv"),
  where_clauses      = meta("where_clauses.csv"),
  external_codelists = meta("external_codelists.csv"),
  study              = "STUDY01",
  standard           = c(name = "SDTMIG", type = "IG", version = "3.4")
)

spec
#> 
#> ── Define-XML 2.1 specification ────────────────────────────────────────────────
#>  Study: STUDY01 | standard: SDTMIG 3.4 (IG)
#>  3 dataset(s), 28 variable(s), 9 codelist(s), 2 method(s), 1 comment(s)
#>  6 value(s) across 2 value list(s), 3 where clause(s), 2 external codelist(s)
#>  [DM] Demographics - 10 variable(s), One record per subject
#>  [AE] Adverse Events - 10 variable(s), One record per adverse event per subject
#>  [VS] Vital Signs - 8 variable(s), One record per vital sign measurement per subject

The required columns are documented in ?define_spec. Optional columns are filled with defaults: purpose becomes Tabulation, mandatory becomes No, order follows the row order within each dataset, archive_location becomes <dataset>.xpt.

spec$variables[1:5, c("dataset", "variable", "type", "length", "order", "key_sequence")]
#> # A tibble: 5 × 6
#>   dataset variable type    length order key_sequence
#>   <chr>   <chr>    <chr>    <int> <int>        <int>
#> 1 DM      STUDYID  text        10     1            1
#> 2 DM      DOMAIN   text         2     2           NA
#> 3 DM      USUBJID  text        20     3            2
#> 4 DM      SUBJID   text        10     4           NA
#> 5 DM      AGE      integer      3     5           NA

Everything wrong at once

Validation happens at the boundary, and reports every problem in one error rather than one per run:

define_spec(
  datasets = data.frame(
    dataset = "DM", label = "Demographics",
    class = "SPECIAL PURPOSE", structure = "One record per subject"
  ),
  variables = data.frame(
    dataset  = c("DM", "EX"),
    variable = c("AGE", "EXDOSE"),
    label    = c("Age", "Dose"),
    type     = c("int", "float"),
    length   = c(3, 8),
    codelist = c("NOT_DEFINED", NA)
  )
)
#> Error in `.abort_problems()`:
#> ! Define-XML specification failed validation:
#>  `variables` refers to dataset(s) absent from `datasets`: EX.
#>  `variables$type`: 'int' not in {text, integer, float, date, time, datetime, partialDate, partialTime, partialDatetime, incompleteDatetime, durationDatetime, intervalDatetime, URI, boolean}.
#>  `variables$codelist` refers to unknown codelists key(s): NOT_DEFINED.

2. Writing Define-XML 2.1

f <- file.path(tempdir(), "define.xml")
write_define(spec, f)

cat(paste(readLines(f)[1:12], collapse = "\n"))
#> <?xml version="1.0" encoding="UTF-8"?>
#> <ODM xmlns="http://www.cdisc.org/ns/odm/v1.3" xmlns:def="http://www.cdisc.org/ns/def/v2.1" xmlns:xlink="http://www.w3.org/1999/xlink" ODMVersion="1.3.2" FileType="Snapshot" FileOID="DEF.STUDY01.20260803022927" CreationDateTime="2026-08-03T02:29:27Z" def:Context="Submission">
#>   <Study OID="SDY.STUDY01">
#>     <GlobalVariables>
#>       <StudyName>STUDY01</StudyName>
#>       <StudyDescription>STUDY01</StudyDescription>
#>       <ProtocolName>STUDY01</ProtocolName>
#>     </GlobalVariables>
#>     <MetaDataVersion OID="MDV.STUDY01" Name="STUDY01" Description="SDTMIG 3.4 STUDY01" def:DefineVersion="2.1.0">
#>       <def:Standards>
#>         <def:Standard OID="STD.1" Name="SDTMIG" Type="IG" Version="3.4" Status="Final"/>
#>       </def:Standards>

Each dataset becomes an ItemGroupDef with its def:Class, def:Structure and the def:leaf that points at the shipped transport file:

doc <- xml2::read_xml(f)
ns <- xml2::xml_ns(doc)
cat(as.character(xml2::xml_find_first(doc, "//d1:ItemGroupDef[@Name='AE']", ns)))
#> <ItemGroupDef OID="IG.AE" Name="AE" Repeating="Yes" IsReferenceData="No" SASDatasetName="AE" Domain="AE" Purpose="Tabulation" def:Structure="One record per adverse event per subject" def:ArchiveLocationID="LF.AE" def:StandardOID="STD.1" def:CommentOID="COM.AESRC">
#>   <Description>
#>     <TranslatedText xml:lang="en">Adverse Events</TranslatedText>
#>   </Description>
#>   <ItemRef ItemOID="IT.AE.STUDYID" OrderNumber="1" Mandatory="Yes" KeySequence="1" Role="Identifier"/>
#>   <ItemRef ItemOID="IT.AE.DOMAIN" OrderNumber="2" Mandatory="Yes" Role="Identifier"/>
#>   <ItemRef ItemOID="IT.AE.USUBJID" OrderNumber="3" Mandatory="Yes" KeySequence="2" Role="Identifier"/>
#>   <ItemRef ItemOID="IT.AE.AESEQ" OrderNumber="4" Mandatory="Yes" KeySequence="3" Role="Identifier"/>
#>   <ItemRef ItemOID="IT.AE.AETERM" OrderNumber="5" Mandatory="Yes" Role="Topic"/>
#>   <ItemRef ItemOID="IT.AE.AEDECOD" OrderNumber="6" Mandatory="Yes" Role="Synonym Qualifier"/>
#>   <ItemRef ItemOID="IT.AE.AEBODSYS" OrderNumber="7" Mandatory="Yes" Role="Record Qualifier"/>
#>   <ItemRef ItemOID="IT.AE.AESEV" OrderNumber="8" Mandatory="No" Role="Record Qualifier"/>
#>   <ItemRef ItemOID="IT.AE.AESER" OrderNumber="9" Mandatory="Yes" Role="Record Qualifier"/>
#>   <ItemRef ItemOID="IT.AE.AESTDTC" OrderNumber="10" Mandatory="No" Role="Timing"/>
#>   <def:Class Name="EVENTS"/>
#>   <def:leaf ID="LF.AE" xlink:href="ae.xpt">
#>     <def:title>ae.xpt</def:title>
#>   </def:leaf>
#> </ItemGroupDef>

Codelists with a decode are written as CodeListItem, those without as EnumeratedItem, and a codelist whose terms live in a dictionary as one ExternalCodeList:

cat(as.character(xml2::xml_find_first(doc, "//d1:CodeList[@OID='CL.SEX']", ns)))
#> <CodeList OID="CL.SEX" Name="Sex" DataType="text">
#>   <CodeListItem CodedValue="F" OrderNumber="1">
#>     <Decode>
#>       <TranslatedText xml:lang="en">Female</TranslatedText>
#>     </Decode>
#>   </CodeListItem>
#>   <CodeListItem CodedValue="M" OrderNumber="2">
#>     <Decode>
#>       <TranslatedText xml:lang="en">Male</TranslatedText>
#>     </Decode>
#>   </CodeListItem>
#> </CodeList>
cat(as.character(xml2::xml_find_first(doc, "//d1:CodeList[@OID='CL.AESEV']", ns)))
#> <CodeList OID="CL.AESEV" Name="Severity/Intensity Scale for Adverse Events" DataType="text">
#>   <EnumeratedItem CodedValue="MILD" OrderNumber="1"/>
#>   <EnumeratedItem CodedValue="MODERATE" OrderNumber="2"/>
#>   <EnumeratedItem CodedValue="SEVERE" OrderNumber="3"/>
#> </CodeList>
cat(as.character(xml2::xml_find_first(doc, "//d1:CodeList[@OID='CL.MEDDRAPT']", ns)))
#> <CodeList OID="CL.MEDDRAPT" Name="MedDRA Preferred Term" DataType="text">
#>   <ExternalCodeList Dictionary="MedDRA" Version="26.1"/>
#> </CodeList>

Value level metadata

VS.VSSTRESN means something different for each VSTESTCD, so it carries a def:ValueListDef rather than one set of attributes. The variable’s ItemDef points at the list, each entry of the list has its own ItemDef, and each is selected by a def:WhereClauseDef:

spec$value_levels[, c("variable", "value", "where_clause", "type", "length", "codelist")]
#> # A tibble: 6 × 6
#>   variable value where_clause type  length codelist 
#>   <chr>    <chr> <chr>        <chr>  <int> <chr>    
#> 1 VSSTRESN SYSBP VS_SYSBP     float      5 NA       
#> 2 VSSTRESN DIABP VS_DIABP     float      5 NA       
#> 3 VSSTRESN PULSE VS_PULSE     float      5 NA       
#> 4 VSSTRESU SYSBP VS_SYSBP     text       4 VSRESU_BP
#> 5 VSSTRESU DIABP VS_DIABP     text       4 VSRESU_BP
#> 6 VSSTRESU PULSE VS_PULSE     text       9 VSRESU_HR
cat(as.character(xml2::xml_find_first(doc, "//def:ValueListDef[@OID='VL.VS.VSSTRESU']", ns)))
#> <def:ValueListDef OID="VL.VS.VSSTRESU">
#>   <ItemRef ItemOID="IT.VS.VSSTRESU.SYSBP" OrderNumber="1" Mandatory="No" Role="Variable Qualifier">
#>     <def:WhereClauseRef WhereClauseOID="WC.VS_SYSBP"/>
#>   </ItemRef>
#>   <ItemRef ItemOID="IT.VS.VSSTRESU.DIABP" OrderNumber="2" Mandatory="No" Role="Variable Qualifier">
#>     <def:WhereClauseRef WhereClauseOID="WC.VS_DIABP"/>
#>   </ItemRef>
#>   <ItemRef ItemOID="IT.VS.VSSTRESU.PULSE" OrderNumber="3" Mandatory="No" Role="Variable Qualifier">
#>     <def:WhereClauseRef WhereClauseOID="WC.VS_PULSE"/>
#>   </ItemRef>
#> </def:ValueListDef>
cat(as.character(xml2::xml_find_first(doc, "//def:WhereClauseDef[@OID='WC.VS_PULSE']", ns)))
#> <def:WhereClauseDef OID="WC.VS_PULSE">
#>   <RangeCheck Comparator="EQ" SoftHard="Soft" def:ItemOID="IT.VS.VSTESTCD">
#>     <CheckValue>PULSE</CheckValue>
#>   </RangeCheck>
#> </def:WhereClauseDef>
cat(as.character(xml2::xml_find_first(doc, "//d1:ItemDef[@OID='IT.VS.VSSTRESU.PULSE']", ns)))
#> <ItemDef OID="IT.VS.VSSTRESU.PULSE" Name="PULSE" DataType="text" Length="9" SASFieldName="VSSTRESU">
#>   <Description>
#>     <TranslatedText xml:lang="en">Pulse Rate Units</TranslatedText>
#>   </Description>
#>   <CodeListRef CodeListOID="CL.VSRESU_HR"/>
#>   <def:Origin Type="Assigned"/>
#> </ItemDef>

IN ('A', 'B') is written as several rows of where_clauses sharing one comparator; they become one RangeCheck with several CheckValue children.

3. Reading it back

read_define() returns a specification identical to the one that was written, so the XML is a storage format rather than a dead end:

back <- read_define(f)
identical(back$variables, spec$variables)
#> [1] TRUE
identical(back$codelists, spec$codelists)
#> [1] TRUE
identical(back$value_levels, spec$value_levels)
#> [1] TRUE
identical(back$where_clauses, spec$where_clauses)
#> [1] TRUE
identical(back$external_codelists, spec$external_codelists)
#> [1] TRUE

4. Checking the metadata against the data

This is the part a spreadsheet cannot do. Supply the real datasets as a named list; names are matched case-insensitively.

data <- define21_example()$data
check_define(spec, data)
#> 
#> ── Define-XML consistency check ────────────────────────────────────────────────
#>  0 errors
#> ! 2 warning(s)
#>  codelist_external: 2
#> # A tibble: 2 × 6
#>   dataset variable value check             severity message                     
#>   <chr>   <chr>    <chr> <chr>             <chr>    <chr>                       
#> 1 AE      AEDECOD  NA    codelist_external warning  Variable 'AE.AEDECOD' uses …
#> 2 AE      AEBODSYS NA    codelist_external warning  Variable 'AE.AEBODSYS' uses…

The two warnings are the MedDRA codelists: their terms are in a dictionary that is not part of the document, so check_define() reports that it could not compare them rather than passing them silently.

Now introduce the three kinds of drift a live study produces - a value longer than the declared length, a codelist term that was never added to the metadata, and a variable that was dropped from the data:

drifted <- data
drifted$DM$USUBJID[1] <- "STUDY01-A-VERY-LONG-SUBJECT-IDENTIFIER"
drifted$DM$SEX[2] <- "U"
drifted$DM$ARM <- NULL

res <- check_define(spec, drifted)
res[, c("dataset", "variable", "check", "severity")]
#> 
#> ── Define-XML consistency check ────────────────────────────────────────────────
#>  3 error(s)
#> ! 2 warning(s)
#>  variable_missing: 1
#>  length_too_short: 1
#>  codelist_value_missing: 1
#>  codelist_external: 2
#> # A tibble: 5 × 4
#>   dataset variable check                  severity
#>   <chr>   <chr>    <chr>                  <chr>   
#> 1 DM      ARM      variable_missing       error   
#> 2 DM      USUBJID  length_too_short       error   
#> 3 DM      SEX      codelist_value_missing error   
#> 4 AE      AEDECOD  codelist_external      warning 
#> 5 AE      AEBODSYS codelist_external      warning

Every message names the fix:

writeLines(strwrap(res$message, width = 78, exdent = 2))
#> Variable 'ARM' is declared for dataset 'DM' but is not a column of it.
#> Variable 'DM.USUBJID' declares Length 20 but the longest value is 38
#>   characters ('STUDY01-A-VERY-LONG-SUBJECT-IDENTIFIER'). Set Length to at
#>   least 38.
#> Variable 'DM.SEX' holds 1 value(s) absent from codelist 'SEX': 'U'.
#> Variable 'AE.AEDECOD' uses external codelist 'MEDDRAPT' (MedDRA 26.1); its
#>   terms are not in the document, so the values were not checked.
#> Variable 'AE.AEBODSYS' uses external codelist 'MEDDRASOC' (MedDRA 26.1); its
#>   terms are not in the document, so the values were not checked.

Value level metadata is checked the same way, on the rows its where clause selects. mmHg is a legal VSSTRESU for the variable, and illegal for PULSE - a disagreement only value level metadata can express and only check_define() will find:

vlm <- data
vlm$VS$VSSTRESU[vlm$VS$VSTESTCD == "PULSE"] <- "mmHg"
vres <- check_define(spec, vlm)
vres[vres$check == "codelist_value_missing", c("variable", "value", "message")]$message
#> [1] "Value 'PULSE' of variable 'VS.VSSTRESU' holds 1 value(s) absent from codelist 'VSRESU_HR': 'mmHg'."

A sensible submission-time gate is simply:

res <- check_define(spec, data)
if (any(res$severity == "error")) stop("define.xml disagrees with the data")
write_define(spec, "define.xml")

What is not covered

Analysis results metadata (ARM) is not written or read. It is a separate model with its own namespace and its own conformance rules, and half of it is worse than none. Nor are annotated-CRF and supplemental document leaves, Alias, SignificantDigits, more than one def:Standard, or more than one FormalExpression per method.

If your submission needs any of those, this package will not produce a complete document - it will produce a smaller, valid one, and say so, rather than emit constructs a validator would reject. The README lists every limit and what happens to each construct on a round trip.