4  SDTM Concepts

NoteSession at a Glance

Total core time: about 90 minutes.

Section Time Type
When Do You Use This? + Learning Objectives 5 min Reading
Background: the model 25 min Reading
Example 1: Anatomy of a domain 20 min Worked example
Example 2: Original result vs standardised result 20 min Worked example
What Can Go Wrong 10 min Reading
Exercises + Comprehension Check 10 min Practice / Self-test

This is the first session of Part 2 and the first that is dense with standard-specific detail. For self-paced study, take the Background in one sitting and the two worked examples in another. It underpins every domain you build afterwards.

TipHow to Use the Code in This Session

Every code chunk below is written so you can copy it into your R console (or an R script) and run it yourself, in the order shown. Where you see a “Run It Yourself” box, stop and run the code above it before reading the explanation that follows. Run R from the project root, as in earlier sessions.

This session also uses the sdtm.terminology package to check controlled-terminology values programmatically, rather than trusting anyone’s memory of what a codelist contains. If renv::restore() has not already provided it, install it once with install.packages("sdtm.terminology").

4.1 When Do You Use This?

Tip

You have the five raw GLPX-1 files and you have been told to “map them to SDTM.” Before you rename a single column you hit the questions this session answers: how many datasets should there be, and which raw file becomes which? What is a single row supposed to represent in each? Which columns are mandatory, which are optional, and who decides? And when the lab sends a result in one form but the standard expects another, where does each version live?

Every one of those is a modelling question, not a coding question. This session is the vocabulary and the mental model you need before The DM Domain, Events and Findings, where you build real domains from the raw data.

It builds on Why Standards (why a shared structure exists) and The Trial: GLPX-1 (the raw data you will now reshape).

4.2 Learning Objectives

After completing this session you will be able to:

  • Explain what SDTM standardises and how the implementation guide relates to the general model
  • Classify a dataset into its observation class from what one row represents
  • Identify the role a variable plays in a record, who it identifies, what it is about, when it happened, and how it qualifies the result
  • Read a variable’s core designation and say what obligation it imposes
  • Distinguish a result as originally collected from its standardised form, and say why both are kept
  • Check a value against CDISC Controlled Terminology programmatically with sdtm.terminology, instead of recalling codelist contents

4.3 Background: The Model Behind the Datasets

Estimated time: ~25 minutes (reading)

ImportantThe one thing to remember

SDTM doesn’t tell you new facts about the trial. It puts the facts you already have into fixed boxes, so that what one row means, what each column’s job is, and what may go in it are the same in every study.

4.3.1 The model and the guide

Two documents govern this work, and they are not the same thing. The SDTM (Study Data Tabulation Model) is the general framework: it defines the kinds of boxes that exist. The SDTMIG (the implementation guide for human clinical trials) is what turns that framework into concrete datasets with named variables you can actually build. The guide is based on the model (SDTMIG v3.4, §2.1); this course uses SDTMIG v3.4, which is meant to be used together with SDTM v2.0 (SDTMIG v3.4, §1.1).

Which version is not a matter of taste. The applicable SDTMIG version for a study is fixed by the study’s start date against the FDA Data Standards Catalog (U.S. Food and Drug Administration 2025). Reading that catalogue for clinical study datasets:

  • SDTMIG v3.2, FDA support ended 2023-12-13.
  • SDTMIG v3.3, supported since 2020-07-07; required for studies starting on or after 2022-03-15.
  • SDTMIG v3.4, supported since 2023-12-13; required for studies starting on or after 2025-03-15; pairs with SDTM v2.0.

GLPX-1 starts in 2024, so its version is settled by that start date falling in the v3.4 support window, not by preference. This is why “SDTM is required” is too loose to be useful: what is required is a specific implementation-guide version over a specific window of study start dates, and the catalogue’s “date support begins / date support ends / date requirement begins” columns are the whole point. (The study’s start date itself travels in the submission in a small dataset called ts.xpt; you will meet it in The Analysis Data Reviewer’s Guide.)

4.3.2 The three general observation classes

Here is the first big idea. Most of what a trial collects about its subjects fits into one of three general observation classes, and which one a dataset belongs to is decided by what one row is (SDTMIG v3.4, §2.3):

Class One row captures… GLPX example
Interventions something done to or taken by the subject study drug exposure (EX)
Events something that happened to the subject an adverse event (AE)
Findings the result of an observation or measurement a lab test (LB), a vital sign (VS)

The distinction is not cosmetic. It determines which variables a dataset is even allowed to have. A findings dataset is built around a result; an events dataset is built around a thing that occurred; an interventions dataset around a treatment. Most trial data, being measurements and responses, lands in Findings (SDTMIG v3.4, §2.3).

Not everything fits the three classes. SDTM also defines special-purpose domains, trial-design datasets, relationship datasets, and study-reference datasets (SDTMIG v3.4, §2.4). Demographics (DM) is the one that matters most here: it is a special-purpose domain: one record per subject (SDTMIG v3.4, §5.2), not a member of any general observation class.

4.3.3 Domains, and the two-character code

A domain is a collection of logically related observations with a common topic, and each domain is one dataset (SDTMIG v3.4, §2.2). Every domain has a unique two-character code, and that code does four jobs at once: it is the dataset name, the value of the DOMAIN variable on every row, the prefix on most of the domain’s variable names, and the domain’s reference in relationship datasets (SDTMIG v3.4, §2.2).

That third job is the one to internalise. The implementation guide writes variable names generically with a -- placeholder (--TESTCD, --ORRES) and you replace the -- with the domain code to get the real name: LBTESTCD in LB, VSTESTCD in VS (SDTMIG v3.4, §2.2, §4.2.2). So --ORRES is not itself a variable; it is a pattern. When this course writes --STRESC, read it as “the STRESC variable of whichever domain we’re in.”

The two-character codes are themselves controlled terminology. They come from a published codelist, not from your imagination. Example 1 checks GLPX’s codes against it.

4.3.4 Identifiers: the spine of every record

Four identifier variables are required in every domain built on a general observation class: STUDYID, DOMAIN, USUBJID, and --SEQ (SDTMIG v3.4, §2.5). Two of them carry rules worth stating precisely, because they are exactly the rules that the raw-data problems from earlier sessions violate:

  • USUBJID: the unique subject identifier, must be unique for each subject across all trials in the submission, not merely within one study (SDTMIG v3.4, §4.2.3). This is the rule that the duplicate-enrolment subject from The DM Domain has to be reconciled against.
  • --SEQ: the sequence number, must be unique within a USUBJID within the domain, so that every record has a stable identity even when a subject has many (SDTMIG v3.4, §4.1.7).

4.3.5 Variable roles, and the topic of a dataset

Every SDTM variable plays a role. The model defines five: Identifier, Topic, Timing, Qualifier, and Rule, and Qualifiers subdivide further into Grouping, Result, Synonym, Variable, and Record qualifiers (SDTMIG v3.4, §2.1). The Rule role belongs to the trial-design datasets and need not concern you while building subject data; the other four are the working vocabulary of every domain:

  • Identifier, who and which record (USUBJID, --SEQ).
  • Topic, what the observation is about.
  • Timing, when it happened (--DTC, VISIT).
  • Qualifier, everything that describes or completes the result (units, severity, the result itself).

The topic variable is the heart of a dataset, and it differs by class (SDTMIG v3.4, §2.1, §2.6): in Findings it is --TESTCD (the thing measured), in Events it is --TERM (the verbatim event, with --DECOD its dictionary-coded companion), and in Interventions it is --TRT (the treatment). Identify the topic and you have identified what the dataset is fundamentally a list of.

4.3.6 Core: which variables you must include

Finally, every variable in a domain model carries a core designation that says how obligatory it is (SDTMIG v3.4, §4.1.5):

  • Required, must be in the dataset and must never be null on any record. These are the identifying and topic variables that make a record meaningful.
  • Expected, must be in the dataset, but a value may be null on some records. If the study did not collect the item at all, the column is still included (empty) with an explanation in the Define-XML.
  • Permissible, include it when the study collected the relevant data, and leave it out entirely when it did not.

“Required” and “Expected” both mean the column is always present; the difference is whether a value is mandatory on every row. Confusing the two is one of the most common conformance findings, and Example 2 depends on the distinction.

4.4 Example 1: Anatomy of a Domain

Estimated time: ~20 minutes (worked example)

Take the five raw GLPX files and, using nothing but the ideas above, answer three questions about each: which SDTM domain does it become, which observation class is that domain in, and what is its topic variable? This is the first thing a programmer does with a new study, and it is done on paper before any code:

glpx_map <- tribble(
  ~raw_file,    ~domain, ~observation_class, ~topic_variable,
  "dm_raw.csv", "DM",    "Special-Purpose",  "(none: one row per subject)",
  "ex_raw.csv", "EX",    "Interventions",    "EXTRT",
  "ae_raw.csv", "AE",    "Events",           "AETERM",
  "lb_raw.csv", "LB",    "Findings",         "LBTESTCD",
  "vs_raw.csv", "VS",    "Findings",         "VSTESTCD"
)

glpx_map
# A tibble: 5 × 4
  raw_file   domain observation_class topic_variable             
  <chr>      <chr>  <chr>             <chr>                      
1 dm_raw.csv DM     Special-Purpose   (none: one row per subject)
2 ex_raw.csv EX     Interventions     EXTRT                      
3 ae_raw.csv AE     Events            AETERM                     
4 lb_raw.csv LB     Findings          LBTESTCD                   
5 vs_raw.csv VS     Findings          VSTESTCD                   

Every domain code in that table is a claim about controlled terminology, so check it rather than trust it. The SDTM Domain Abbreviation codelist is C66734; ask the terminology package whether each code is a real member:

glpx_map |>
  mutate(valid_domain_code = map_lgl(domain, \(d) is_term(d, clst_code = "C66734"))) |>
  select(raw_file, domain, valid_domain_code)
# A tibble: 5 × 3
  raw_file   domain valid_domain_code
  <chr>      <chr>  <lgl>            
1 dm_raw.csv DM     TRUE             
2 ex_raw.csv EX     TRUE             
3 ae_raw.csv AE     TRUE             
4 lb_raw.csv LB     TRUE             
5 vs_raw.csv VS     TRUE             
TipRun It Yourself

Run both chunks. Every row of the second should read TRUE: DM, EX, AE, LB, VS are all valid domain codes. Now change one code to something plausible-but-wrong ("LBS", say, or "VITALS") and re-run. Watch it come back FALSE. That is the whole discipline of this course in one line: you did not have to know the codelist, you had to check it.

NoteReading the Output Line by Line
Column What it tells you
domain The two-character code each file becomes. It will be the dataset name, the DOMAIN value on every row, and the prefix on the domain’s variables.
observation_class Decided purely by what one row is. LB and VS are both Findings because each row is a measurement result; AE is Events because each row is something that happened; EX is Interventions because each row is a treatment given.
topic_variable What the dataset is a list of. Notice the topic follows the class: --TESTCD for the two Findings domains, --TERM for the Events domain, --TRT for the Interventions domain.
DM row Special-purpose, so it has no observation-class topic variable; its structure is simply one record per subject. You build it in The DM Domain.

Whatever the domain, four identifier variables form its spine (STUDYID, DOMAIN, USUBJID, --SEQ) before a single domain-specific column is added.

4.5 Example 2: Original Result vs Standardised Result

Estimated time: ~20 minutes (worked example)

This is where Findings pay off the modelling. A findings result is not one value in one column; it is kept twice (as collected, and standardised) and both are preserved. Take one subject’s glucose across visits from the raw lab file:

lb <- read_csv("data/raw/lb_raw.csv", show_col_types = FALSE,
               col_types = cols(.default = col_character()))

gluc <- lb |>
  filter(subjid == "101-037", test == "GLUC") |>
  select(subjid, visit, test, result, unit)

gluc
# A tibble: 8 × 5
  subjid  visit     test  result unit  
  <chr>   <chr>     <chr> <chr>  <chr> 
1 101-037 SCREENING GLUC  9.3    mmol/L
2 101-037 BASELINE  GLUC  10.7   mmol/L
3 101-037 WEEK 4    GLUC  9.9    mmol/L
4 101-037 WEEK 8    GLUC  9.6    <NA>  
5 101-037 WEEK 12   GLUC  11.2   mmol/L
6 101-037 WEEK 16   GLUC  7.7    mmol/L
7 101-037 WEEK 20   GLUC  9.3    mmol/L
8 101-037 WEEK 26   GLUC  9.7    mmol/L

Now map those raw columns onto the SDTM findings result variables. The result as collected goes to LBORRES, with its unit in LBORRESU (SDTMIG v3.4, §4.5.1.1). The standardised character result goes to LBSTRESC; where that value is numeric it is also written, as a true number, to LBSTRESN (SDTMIG v3.4, §4.5.1.1). Glucose here is already in the standard unit, so standardising is a copy, but the structure is the lesson, not the arithmetic:

mapped <- gluc |>
  transmute(
    USUBJID  = subjid,
    LBTESTCD = test,
    LBORRES  = result,              # result as originally collected
    LBORRESU = unit,                # original unit
    LBSTRESC = result,              # standardised, character
    LBSTRESN = as.numeric(result),  # standardised, numeric
    LBSTRESU = unit                 # standard unit
  )

mapped
# A tibble: 8 × 7
  USUBJID LBTESTCD LBORRES LBORRESU LBSTRESC LBSTRESN LBSTRESU
  <chr>   <chr>    <chr>   <chr>    <chr>       <dbl> <chr>   
1 101-037 GLUC     9.3     mmol/L   9.3           9.3 mmol/L  
2 101-037 GLUC     10.7    mmol/L   10.7         10.7 mmol/L  
3 101-037 GLUC     9.9     mmol/L   9.9           9.9 mmol/L  
4 101-037 GLUC     9.6     <NA>     9.6           9.6 <NA>    
5 101-037 GLUC     11.2    mmol/L   11.2         11.2 mmol/L  
6 101-037 GLUC     7.7     mmol/L   7.7           7.7 mmol/L  
7 101-037 GLUC     9.3     mmol/L   9.3           9.3 mmol/L  
8 101-037 GLUC     9.7     mmol/L   9.7           9.7 mmol/L  

Both the test code and the unit are controlled terminology, so confirm them against the published codelists (C65047 for the lab test code, C71620 for units) instead of assuming:

c(
  gluc_is_valid_testcd = is_term("GLUC",   clst_code = "C65047"),
  mmolL_is_valid_unit  = is_term("mmol/L", clst_code = "C71620")
)
gluc_is_valid_testcd  mmolL_is_valid_unit 
                TRUE                 TRUE 
TipRun It Yourself

Run the three chunks. Both CT checks return TRUE: the vendor’s GLUC and mmol/L happen to be legal SDTM values. But look carefully at the mapped table’s week-8 row before reading on. Something is missing, and it is not the result.

NoteReading the Output Line by Line
Variable Value at week 8 What it means
LBORRES 9.6 The result as collected. It is present.
LBORRESU (blank) The unit is missing. This is defect D7 from the trial simulator, a genuinely blank unit on one transferred record.
LBSTRESC / LBSTRESN 9.6 / 9.6 The result standardises fine; the number was never the problem.
LBSTRESU (blank) The missing unit propagates, because you cannot standardise a unit you do not have.

LBORRES is an Expected variable, always present, occasionally null (SDTMIG v3.4, §4.5.1.1). The unit’s blankness is not a model violation; it is a data problem the model makes visible. And crucially, a blank is not a valid unit value:

is_term("", clst_code = "C71620")
[1] FALSE

The check returns FALSE: an empty string is not a member of the UNIT codelist. So the week-8 glucose cannot be silently accepted: its unit has to be recovered from the lab’s documentation or queried, a piece of work you do in Findings. The point for now: the standard did not fix the data, it located the problem, and it located it in a specific variable with a specific rule, not in a vague sense that “something looks off.”

4.6 What Can Go Wrong

Estimated time: ~10 minutes (reading)

Treating SDTM as “rename the columns”

The recurring error from Why Standards, now with a name. Renaming is a fraction of the work; classifying each dataset into its observation class, giving each record the right identifier spine, and constraining values to controlled terminology are the substance. If your mental model is a lookup table of old name → new name, the DM and findings sessions will not make sense.

Overwriting the original result with the standardised one

--ORRES and --STRESC are different variables on purpose (SDTMIG v3.4, §4.5.1.1). The original is kept even when standardising changes nothing, so that a reviewer can always see what was collected. Collapse them into one column and you have destroyed traceability, which is one of the things the whole tabulation layer exists to preserve.

Inventing a test code or a unit

Every --TESTCD and every unit is drawn from a published codelist. The temptation to write "HGBA1C" or "mmol/l" because it looks right is exactly the failure this course is built to prevent. Check with is_term() and let the package be right so you don’t have to remember.

Computing study day with a day zero

Timing has its own trap, addressed in the next section’s cross-links: there is no study day 0 (SDTMIG v3.4, §4.4.4). More on this in The DM Domain, where RFSTDTC is set.

Assuming one subject means one row, everywhere

True only in DM, the special-purpose domain (SDTMIG v3.4, §5.2). In a findings domain a subject has one row per test per visit; in an events domain, one row per event. “How many rows should this subject have?” is answered by the domain’s observation class, never by a habit.

WarningCommon misinterpretations

“SDTM is a database schema” It is a submission format: flat, one dataset per domain, built to be read and archived by a regulator, not to be queried efficiently. Its shape follows that purpose, not database-normalisation instincts.

“Required and Expected are the same: both are mandatory” Both mean the column is always present. Only Required forbids a null value on every row; an Expected variable may be null where the datum is genuinely absent (SDTMIG v3.4, §4.1.5). The week-8 missing unit in Example 2 is legal precisely because LBORRESU is not Required.

“The -- is part of the variable name” It is a placeholder the guide uses for the domain prefix. The real variable is LBTESTCD or VSTESTCD; --TESTCD names the pattern across domains (SDTMIG v3.4, §2.2).

“If a value passes is_term(), the record is correct” It means the value is legal controlled terminology, nothing more. A legal LBTESTCD on the wrong row, or a valid unit that is simply the wrong unit, both pass. Conformance is necessary, not sufficient: the lesson that closes Why Standards.

4.7 Exercises

4.7.1 Exercise 1 (Guided): Classify Two More Domains

Estimated time: ~10 minutes

A later GLPX analysis will need two datasets this course has not built: concomitant medications (what other drugs subjects were taking) and disposition (how and when each subject left the study).

  1. For each, name the general observation class you would put it in, and justify the choice from what one row represents.
  2. Name the topic variable each would use (as a -- pattern), based on its class.
  3. Propose a plausible two-character domain code for each, then (this is the point) describe how you would check whether your proposed code is real, without stating from memory whether it is.

Worked solutions are in the paid tier; the habit the exercise builds (class first, then topic, then check the code) is the whole method.

4.8 Comprehension Check

Estimated time: ~10 minutes

  1. What is the difference between the SDTM and the SDTMIG, and why does this course use v3.4 specifically?
  2. A dataset has one row per subject per test per visit. Which observation class is it, and what is its topic variable?
  3. --STRESC and --STRESN are both standardised results. Why are there two, and when is --STRESN null?
  4. A variable is marked Expected. The study never collected that item. What must appear in the submitted dataset, and what must not?
  5. You need the SDTM unit value for “beats per minute.” A colleague says it is "bpm". How do you settle it without trusting either of your memories?
  1. The SDTM is the general model (the kinds of boxes that exist); the SDTMIG is the implementation guide that turns it into named datasets and variables (SDTMIG v3.4, §2.1). The version is not a preference: the FDA Data Standards Catalog ties the required SDTMIG version to the study’s start date, and GLPX-1’s 2024 start falls in the v3.4 support window (U.S. Food and Drug Administration 2025).

  2. Findings: one row is the result of a measurement (SDTMIG v3.4, §2.3). Its topic variable is --TESTCD (SDTMIG v3.4, §2.1), i.e. LBTESTCD or VSTESTCD once the domain prefix is applied.

  3. --STRESC holds the standardised result in character form, so it can carry any result at all; --STRESN holds it as a true number for the results that are numeric (SDTMIG v3.4, §4.5.1.1). --STRESN is null whenever the standardised result is not numeric (a category, a >10,000, a blank), which is why the character version exists.

  4. The column must still be present, empty, with a comment in the Define-XML stating the study did not collect the item; a value is not required on any row (SDTMIG v3.4, §4.1.5). What must not happen is dropping the column, that is the difference between Expected and Permissible.

  5. Check it against the UNIT codelist with the terminology package rather than debating: is_term("bpm", clst_code = "C71620") versus is_term("beats/min", clst_code = "C71620"). One is a member and one is not, and the package settles it in a line: the same move Example 2 used for the glucose unit.

U.S. Food and Drug Administration. 2025. FDA Data Standards Catalog, Version 11.0.” U.S. Food; Drug Administration. https://www.fda.gov/industry/fda-resources-data-standards/study-data-standards-resources.