Define.xml preparation
Lesson 9 — Clinical Programming with R
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 < 65 then AGEGR1 = '<65'; else if 65 <= AGE <= 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 # -> MethodDefThe 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 27Reconcile 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>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:
Predecessorprovides SDTM traceability,Derivedrequires a method - Value-level metadata is required for
AVAL/AVALCin 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.