Define.xml preparation

Lesson 9 — Clinical Programming with R

Lesson 9 of 12 Advanced ~80 min

Learning objectives

  • Explain what define.xml contains and why it exists
  • Structure the metadata that generates it
  • Produce value-level metadata correctly
  • Generate define.xml from a specification in R
  • Validate the result and understand common findings
  • Know where Dataset-JSON fits

What define.xml is

The data definition document for a submission — a machine-readable description of every dataset, variable, codelist and derivation. A reviewer opens it (via its stylesheet, in a browser) to understand what they have been sent, without opening the data.

It is a required component of a CDISC-conformant submission, and it is the component most often deficient. A common finding: datasets that conform perfectly, described by a define.xml that does not match them.

Current version: Define-XML 2.1, based on ODM 1.3.2.

Structure

<ODM>
  <Study OID="ABC-101">
    <GlobalVariables>
      <StudyName>ABC-101</StudyName>
      <StudyDescription>A Phase 3 Study of ...</StudyDescription>
      <ProtocolName>ABC-101</ProtocolName>
    </GlobalVariables>

    <MetaDataVersion OID="MDV.ADaM.1" Name="ADaM Metadata"
                     def:DefineVersion="2.1.0" def:StandardName="ADaMIG"
                     def:StandardVersion="1.3">

      <!-- One ItemGroupDef per dataset -->
      <ItemGroupDef OID="IG.ADSL" Name="ADSL" Repeating="No"
                    Purpose="Analysis" def:Structure="One record per subject"
                    def:Class="SUBJECT LEVEL ANALYSIS DATASET"
                    def:ArchiveLocationID="LF.ADSL">
        <Description><TranslatedText>Subject-Level Analysis Dataset</TranslatedText></Description>
        <ItemRef ItemOID="IT.ADSL.USUBJID" OrderNumber="1" Mandatory="Yes" KeySequence="1"/>
        <ItemRef ItemOID="IT.ADSL.AGE"     OrderNumber="2" Mandatory="No"/>
      </ItemGroupDef>

      <!-- One ItemDef per variable -->
      <ItemDef OID="IT.ADSL.AGE" Name="AGE" DataType="integer" Length="8"
               SASFieldName="AGE">
        <Description><TranslatedText>Age</TranslatedText></Description>
        <def:Origin Type="Predecessor">
          <def:SourceItems><def:SourceItem Domain="DM" Name="AGE"/></def:SourceItems>
        </def:Origin>
      </ItemDef>

      <!-- Codelists -->
      <CodeList OID="CL.SEX" Name="Sex" DataType="text">
        <CodeListItem CodedValue="M"><Decode><TranslatedText>Male</TranslatedText></Decode></CodeListItem>
        <CodeListItem CodedValue="F"><Decode><TranslatedText>Female</TranslatedText></Decode></CodeListItem>
      </CodeList>

      <!-- Derivations -->
      <MethodDef OID="MT.AGEGR1" Name="Age group derivation" Type="Computation">
        <Description><TranslatedText>
          If AGE &lt; 65 then AGEGR1 = '&lt;65'; else if 65 &lt;= AGE &lt;= 80 ...
        </TranslatedText></Description>
      </MethodDef>

      <!-- Value-level metadata -->
      <def:ValueListDef OID="VL.ADLB.AVAL">
        <ItemRef ItemOID="IT.ADLB.AVAL.ALT" OrderNumber="1" Mandatory="No">
          <def:WhereClauseRef WhereClauseOID="WC.ADLB.PARAMCD.ALT"/>
        </ItemRef>
      </def:ValueListDef>

    </MetaDataVersion>
  </Study>
</ODM>

The key elements

Element Describes
ItemGroupDef A dataset: name, label, structure, class, key variables
ItemDef A variable: name, type, length, label, origin
ItemRef A variable’s membership in a dataset: order, mandatory, key sequence
CodeList Controlled terminology, with coded values and decodes
MethodDef A derivation, in prose
def:ValueListDef Value-level metadata — different rules per PARAMCD
def:WhereClauseDef The condition selecting a value-level subset
def:leaf A link to the dataset file or a supporting document

Origin types

Every variable needs an origin, and getting these right is a frequent finding:

Origin Means Example
Collected (CRF) Recorded on the CRF AETERM
Derived Computed — requires a MethodDef AGEGR1, TRTDURD
Assigned Set by the sponsor, not computed STUDYID
Protocol Specified in the protocol ARM
Predecessor Copied from another dataset unchanged AGE from DM.AGE

Predecessor is the one that matters most in ADaM: it is what provides traceability back to SDTM. A variable copied from SDTM without change should be Predecessor with the source named, not Derived.

Value-level metadata

The reason define.xml is more than a variable list. In a BDS dataset, AVAL means something different for each PARAMCD — different units, different ranges, different derivations.

value_level <- tibble::tribble(
  ~dataset, ~variable, ~where_clause,        ~type,    ~length, ~significant_digits, ~origin,    ~method,
  "ADLB",   "AVAL",    "PARAMCD EQ 'ALT'",   "float",  8,       2,                   "Derived",  "MT.ALT",
  "ADLB",   "AVAL",    "PARAMCD EQ 'AST'",   "float",  8,       2,                   "Derived",  "MT.AST",
  "ADLB",   "AVAL",    "PARAMCD EQ 'BILI'",  "float",  8,       3,                   "Derived",  "MT.BILI",
  "ADVS",   "AVAL",    "PARAMCD EQ 'SYSBP'", "integer",8,       0,                   "Predecessor", NA
)

Without this, a reviewer sees AVAL: float, length 8 and learns nothing. With it, they can see that ALT is reported to two decimal places in U/L and derived in a specific way.

Value-level metadata is required for AVAL, AVALC and typically PARAM in every BDS dataset. It is one of the most commonly incomplete parts of a define.xml.

Generating define.xml

From metacore

If the specification is already a metacore object (lesson 5), most of the content is there:

library(metacore)

meta <- spec_to_metacore("metadata/adam_spec.xlsx")

meta$ds_spec      # -> ItemGroupDef
meta$var_spec     # -> ItemDef
meta$ds_vars      # -> ItemRef
meta$value_spec   # -> ValueListDef, WhereClauseDef
meta$codelist     # -> CodeList
meta$derivations  # -> MethodDef

The mapping is direct because metacore’s structure was designed around Define-XML.

datasetjson and the ecosystem

The pharmaverse define.xml tooling is less mature than the rest of the stack. Current options:

Approach Notes
defineR Generates Define-XML 2.0/2.1 from an Excel specification
xml2 by hand Full control; considerable work
Pinnacle 21 Enterprise Commercial; the industry default
Commercial metadata systems What most large sponsors use

Building it with xml2 is entirely feasible and instructive:

library(xml2)

build_define <- function(meta, study_name, output_path) {

  doc <- xml_new_root(
    "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        = paste0("DEFINE.", study_name),
    CreationDateTime = format(Sys.time(), "%Y-%m-%dT%H:%M:%S"),
    "def:Context"  = "Submission"
  )

  study <- xml_add_child(doc, "Study", OID = study_name)

  gv <- xml_add_child(study, "GlobalVariables")
  xml_add_child(gv, "StudyName", study_name)
  xml_add_child(gv, "StudyDescription", "A Phase 3 Randomised Study")
  xml_add_child(gv, "ProtocolName", study_name)

  mdv <- xml_add_child(
    study, "MetaDataVersion",
    OID  = "MDV.ADaM.1",
    Name = "ADaM Metadata",
    "def:DefineVersion"   = "2.1.0",
    "def:StandardName"    = "ADaMIG",
    "def:StandardVersion" = "1.3"
  )

  # --- Datasets -------------------------------------------------------------
  purrr::pwalk(meta$ds_spec, function(dataset, structure, label, ...) {
    ig <- xml_add_child(
      mdv, "ItemGroupDef",
      OID       = paste0("IG.", dataset),
      Name      = dataset,
      Repeating = if (grepl("one record per subject$", tolower(structure))) "No" else "Yes",
      Purpose   = "Analysis",
      "def:Structure"         = structure,
      "def:ArchiveLocationID" = paste0("LF.", dataset)
    )
    desc <- xml_add_child(ig, "Description")
    xml_add_child(desc, "TranslatedText", label, "xml:lang" = "en")

    vars <- dplyr::filter(meta$ds_vars, dataset == !!dataset)
    purrr::pwalk(vars, function(variable, order, mandatory, key_seq, ...) {
      attrs <- list(
        ItemOID     = paste0("IT.", dataset, ".", variable),
        OrderNumber = as.character(order),
        Mandatory   = if (isTRUE(mandatory)) "Yes" else "No"
      )
      if (!is.na(key_seq)) attrs$KeySequence <- as.character(key_seq)
      do.call(xml_add_child, c(list(ig, "ItemRef"), attrs))
    })

    # Archive location
    leaf <- xml_add_child(ig, "def:leaf",
                          ID = paste0("LF.", dataset),
                          "xlink:href" = paste0(tolower(dataset), ".xpt"))
    xml_add_child(leaf, "def:title", paste0(tolower(dataset), ".xpt"))
  })

  # --- Variables ------------------------------------------------------------
  purrr::pwalk(meta$var_spec, function(variable, label, type, length, format, ...) {
    id <- xml_add_child(
      mdv, "ItemDef",
      OID          = paste0("IT.", variable),
      Name         = variable,
      DataType     = type,
      Length       = as.character(length),
      SASFieldName = variable
    )
    desc <- xml_add_child(id, "Description")
    xml_add_child(desc, "TranslatedText", label, "xml:lang" = "en")
  })

  # --- Codelists ------------------------------------------------------------
  purrr::walk(unique(meta$codelist$code_id), function(cid) {
    cl_rows <- dplyr::filter(meta$codelist, code_id == cid)
    cl <- xml_add_child(mdv, "CodeList",
                        OID = paste0("CL.", cid),
                        Name = cid,
                        DataType = "text")
    purrr::pwalk(cl_rows, function(code, decode, ...) {
      item <- xml_add_child(cl, "CodeListItem", CodedValue = code)
      dec  <- xml_add_child(item, "Decode")
      xml_add_child(dec, "TranslatedText", decode, "xml:lang" = "en")
    })
  })

  write_xml(doc, output_path)
  cli::cli_alert_success("Wrote {.path {output_path}}")
  invisible(output_path)
}

This is a simplified generator — a production one also handles value-level metadata, where clauses, method definitions, comments, supporting documents and the analysis results metadata. But the shape is right, and building one is the fastest way to understand what the standard actually requires.

Validation

1. XML well-formedness      — does it parse?
2. Schema validation        — does it conform to the Define-XML schema?
3. Conformance rules        — Pinnacle 21 / CDISC CORE
4. Consistency with data    — does it describe the datasets you are shipping?

Step 4 is the one that gets missed:

validate_define_against_data <- function(define_path, data_dir) {
  doc <- xml2::read_xml(define_path)
  ns  <- xml2::xml_ns(doc)

  # What define.xml says
  defined <- xml2::xml_find_all(doc, "//d1:ItemGroupDef", ns) |>
    purrr::map(function(ig) {
      ds <- xml2::xml_attr(ig, "Name")
      vars <- xml2::xml_find_all(ig, ".//d1:ItemRef", ns) |>
        xml2::xml_attr("ItemOID") |>
        sub("^IT\\.[^.]+\\.", "", x = _)
      tibble::tibble(dataset = ds, variable = vars)
    }) |>
    purrr::list_rbind()

  # What the datasets actually contain
  actual <- list.files(data_dir, "\\.xpt$", full.names = TRUE) |>
    purrr::map(function(f) {
      d <- haven::read_xpt(f)
      tibble::tibble(
        dataset  = toupper(tools::file_path_sans_ext(basename(f))),
        variable = names(d),
        length   = purrr::map_int(d, ~ if (is.character(.x))
                                   max(c(1L, nchar(.x, type = "bytes")), na.rm = TRUE)
                                 else 8L)
      )
    }) |>
    purrr::list_rbind()

  dplyr::full_join(defined, actual, by = c("dataset", "variable")) |>
    dplyr::mutate(
      issue = dplyr::case_when(
        is.na(length) ~ "In define.xml but not in the dataset",
        TRUE          ~ NA_character_
      )
    ) |>
    dplyr::filter(!is.na(issue) | !variable %in% defined$variable)
}

Run it before every delivery. A define.xml describing a variable that was dropped from the dataset, or omitting one that was added, is a guaranteed finding.

Common findings

Finding Cause Prevention
Variable in data not in define Define generated before the final data Generate from the same spec the data uses
Length mismatch Define says 200, data max is 12 Compute lengths from the data
Missing value-level metadata Not produced for AVAL/AVALC Required for every BDS dataset
Origin Derived with no method Method omitted Every Derived needs a MethodDef
Codelist not referenced Defined but never linked from an ItemDef Cross-check
Predecessor without source Traceability broken Name the source dataset and variable
Broken def:leaf link File renamed after generation Validate links
Define version mismatch 2.0 attributes in a 2.1 document Pick one version

Dataset-JSON

CDISC’s replacement for XPT V5. Same content, JSON encoding, no 8-character names, no 200-byte limit, UTF-8 throughout.

library(datasetjson)

ds <- dataset_json(
  adsl,
  file_oid   = "www.sponsor.com/studies/ABC101/analysis/adam/datasets/adsl",
  last_modified = format(Sys.time(), "%Y-%m-%dT%H:%M:%S"),
  originator = "Sponsor Name",
  sys        = "R",
  sys_version = as.character(getRversion()),
  study      = "ABC-101",
  metadata_version = "MDV.ADaM.1",
  item_oid   = "IG.ADSL",
  name       = "ADSL",
  dataset_label = "Subject-Level Analysis Dataset"
)

write_dataset_json(ds, "data/submission/adsl.json")

Status as of 2026: the FDA has run a pilot accepting Dataset-JSON, and CDISC has published the standard, but XPT V5 remains the required format for most submissions. Produce Dataset-JSON if your regulatory affairs function asks for it; produce XPT regardless.

The practical argument for it is real: XPT’s 8-character names and 200-byte values force compromises that Dataset-JSON does not.

Common mistakes

Mistake Consequence Fix
Writing define.xml by hand at the end Diverges from the data Generate from the spec
Define and data from different sources Mismatches One specification, both outputs
Skipping value-level metadata Major finding Required for BDS AVAL
Lengths from the spec, not the data Length mismatch findings Compute from actual data
Derived origin, no method Finding Every derived variable gets a MethodDef
Not validating against the shipped data Guaranteed finding Cross-check before delivery
Copying last study’s define Wrong study, stale metadata Regenerate

Exercise 9.1 — Build the value-level metadata for a BDS dataset

For an ADLB with parameters ALT, AST, BILI and ALP, produce the value-level metadata for AVAL and PARAM, including where clauses, types, significant digits and origins.

Show solution
library(dplyr); library(tidyr)

# --- Parameter reference ----------------------------------------------------
params <- tibble::tribble(
  ~PARAMCD, ~PARAM,                                  ~unit,   ~sig_digits, ~type,
  "ALT",    "Alanine Aminotransferase (U/L)",        "U/L",   2,           "float",
  "AST",    "Aspartate Aminotransferase (U/L)",      "U/L",   2,           "float",
  "BILI",   "Bilirubin (umol/L)",                    "umol/L",3,           "float",
  "ALP",    "Alkaline Phosphatase (U/L)",            "U/L",   1,           "float"
)

# --- Where clauses ----------------------------------------------------------
where_clauses <- params |>
  transmute(
    where_oid    = paste0("WC.ADLB.PARAMCD.", PARAMCD),
    dataset      = "ADLB",
    variable     = "PARAMCD",
    comparator   = "EQ",
    value        = PARAMCD,
    description  = paste0("PARAMCD EQ '", PARAMCD, "'")
  )

# --- Value-level metadata for AVAL ------------------------------------------
vlm_aval <- params |>
  transmute(
    value_oid   = paste0("IT.ADLB.AVAL.", PARAMCD),
    dataset     = "ADLB",
    variable    = "AVAL",
    where_oid   = paste0("WC.ADLB.PARAMCD.", PARAMCD),
    order       = row_number(),
    mandatory   = "No",
    type        = type,
    length      = 8L,
    sig_digits  = sig_digits,
    label       = paste0("Analysis Value (", unit, ")"),
    origin      = "Predecessor",
    source      = paste0("LB.LBSTRESN where LBTESTCD = '", PARAMCD, "'"),
    method_oid  = NA_character_
  )

# --- Value-level metadata for PARAM -----------------------------------------
vlm_param <- params |>
  transmute(
    value_oid   = paste0("IT.ADLB.PARAM.", PARAMCD),
    dataset     = "ADLB",
    variable    = "PARAM",
    where_oid   = paste0("WC.ADLB.PARAMCD.", PARAMCD),
    order       = row_number(),
    mandatory   = "Yes",
    type        = "text",
    length      = max(nchar(PARAM)),
    sig_digits  = NA_integer_,
    label       = "Parameter",
    origin      = "Assigned",
    source      = NA_character_,
    method_oid  = NA_character_
  )

value_level <- bind_rows(vlm_aval, vlm_param)

Computing lengths and significant digits from the actual data

Hard-coding these is how length-mismatch findings happen. Derive them:

derive_vlm_from_data <- function(adlb) {
  adlb |>
    summarise(
      n            = sum(!is.na(AVAL)),
      actual_type  = if (all(AVAL == round(AVAL), na.rm = TRUE)) "integer" else "float",
      max_decimals = max(
        nchar(sub("^[^.]*\\.?", "", format(AVAL, scientific = FALSE, trim = TRUE))),
        na.rm = TRUE
      ),
      param_length = max(nchar(PARAM, type = "bytes"), na.rm = TRUE),
      .by = PARAMCD
    )
}

derive_vlm_from_data(adlb)
#> # A tibble: 4 x 5
#>   PARAMCD     n actual_type max_decimals param_length
#>   <chr>   <int> <chr>              <int>        <int>
#> 1 ALT      1224 float                  2           30
#> 2 AST      1224 float                  2           32
#> 3 BILI     1224 float                  3           19
#> 4 ALP      1224 float                  1           27

Reconcile this against the specification and fail on any difference:

check_vlm <- function(spec_vlm, actual) {
  spec_vlm |>
    filter(variable == "AVAL") |>
    mutate(PARAMCD = sub("^.*\\.", "", value_oid)) |>
    left_join(actual, by = "PARAMCD") |>
    filter(sig_digits != max_decimals | type != actual_type) |>
    select(PARAMCD, spec_digits = sig_digits, actual_digits = max_decimals,
           spec_type = type, actual_type)
}

The XML fragment

<def:ValueListDef OID="VL.ADLB.AVAL">
  <ItemRef ItemOID="IT.ADLB.AVAL.ALT" OrderNumber="1" Mandatory="No">
    <def:WhereClauseRef WhereClauseOID="WC.ADLB.PARAMCD.ALT"/>
  </ItemRef>
  <ItemRef ItemOID="IT.ADLB.AVAL.AST" OrderNumber="2" Mandatory="No">
    <def:WhereClauseRef WhereClauseOID="WC.ADLB.PARAMCD.AST"/>
  </ItemRef>
</def:ValueListDef>

<def:WhereClauseDef OID="WC.ADLB.PARAMCD.ALT">
  <RangeCheck Comparator="EQ" SoftHard="Soft" def:ItemOID="IT.ADLB.PARAMCD">
    <CheckValue>ALT</CheckValue>
  </RangeCheck>
</def:WhereClauseDef>

<ItemDef OID="IT.ADLB.AVAL.ALT" Name="AVAL" DataType="float"
         Length="8" SignificantDigits="2" SASFieldName="AVAL">
  <Description>
    <TranslatedText xml:lang="en">Analysis Value (U/L)</TranslatedText>
  </Description>
  <def:Origin Type="Predecessor">
    <def:SourceItems>
      <def:SourceItem Domain="LB" Name="LBSTRESN"/>
    </def:SourceItems>
  </def:Origin>
</ItemDef>
Note that the AVAL variable itself also needs an ItemDef with a def:ValueListRef pointing at VL.ADLB.AVAL — the value list is referenced from the variable definition, not attached to it. Omitting that reference is a common finding: the value-level metadata exists but nothing points to it.

Recap

  • Define.xml describes every dataset, variable, codelist and derivation
  • Origin types matter: Predecessor provides SDTM traceability, Derived requires a method
  • Value-level metadata is required for AVAL/AVALC in every BDS dataset
  • Generate it from the same specification that produced the data
  • Compute lengths and significant digits from the actual data, not the spec
  • Validate against the shipped datasets before delivery — mismatches are guaranteed findings
  • Dataset-JSON is the coming replacement for XPT; produce XPT regardless for now

Next: xportr.

Back to top