Themes, ARDs, and Custom Extensions for Pharma
Daniel D. Sjoberg and Shannon Pileggi
R/Pharma 2026

This work is licensed under a Creative Commons Attribution-ShareAlike 4.0 International License (CC BY-SA4.0).
Please ask questions at any time!

Analysis Results Datasets — build the numbers first with {cards}, so every table has something auditable behind it.
{gtsummary} fundamentals — tbl_summary(), hierarchical adverse event tables, merging and stacking.
ARD-first tables — every cell traceable, and QC by comparing one ARD against another.
Print engines — {gt}, {flextable}, and submission-ready Word output.
Adopting {gtsummary} — a house theme, wrappers of your own, and {crane}.
Coding agents — writing your standards down so an agent follows them without being reminded.
We are assuming you:
Are comfortable in R and work in the tidyverse — the native pipe, {dplyr}, {tidyr}.
Have used {gtsummary} before, even just once.
This is a workshop about adopting {gtsummary} across an organization — not a first tour of the package. We cover only the very basics before moving on to themes, ARDs, and extensions, so a little background with the package goes a long way.
AGENTS.md and SKILL.mdTwo ways to write your standards down for a coding agent. Both are committed to the repo, so every agent on the project reads the same thing.
AGENTS.md — how we work in this repo, every time. House code style, where the data comes from, project layout, how to build. Lives at the root; the agent loads it for every task.
SKILL.md — how to do one particular task, loaded only when that task comes up. Its description is the trigger the agent matches against, so it is written as concrete situations, not a summary.
This repo ships two skills we wrote: ard-creation and gtsummary-tables, in .agents/skills/. We will read both later today. ➡️
Dataset that stores key metadata and raw results from analysis
Long dataset that is 1 record per result value
May contain formatted values as well
The ARD can be used to to subsequently create tables and figures.
The ARD does not describe the layout of the results
ARDs give us the opportunity to rethink QC
QC can be focused on the raw value, as well as the formatted display
ARDs can be flexibly saved to different file types
Objectives include:
The ARS provides a metadata-driven infrastructure for analysis
{cards} serves as the engine for the analysis and {gtsummary} as the builder-of-tables
Part of the Pharmaverse
Collaboration among Roche, GSK, Novartis, Eli Lilly, Pfizer, Clymb, Kardigan
Contains a variety of utilities for making ARDs
Can be used within the ARS workflow and separately
59K downloads per month 🤯
ADSL from pharmaverseadam
ADAE from pharmaverseadam
ard_tabulate()n, %, N by default# An ARD data frame: 6 × 9
variable variable_level context stat_name stat_label stat fmt_fun warning error
* <chr> <list> <chr> <chr> <chr> <list> <list> <list> <list>
1 AGEGR1 18-64 tabulate n n 33 0 <NULL> <NULL>
2 AGEGR1 18-64 tabulate N N 254 0 <NULL> <NULL>
3 AGEGR1 18-64 tabulate p % 0.130 <fn> <NULL> <NULL>
4 AGEGR1 >64 tabulate n n 221 0 <NULL> <NULL>
5 AGEGR1 >64 tabulate N N 254 0 <NULL> <NULL>
6 AGEGR1 >64 tabulate p % 0.870 <fn> <NULL> <NULL>
ard_tabulate()n, %, N by default# An ARD data frame: 12 × 11
group1 group1_level variable variable_level context stat_name stat_label stat fmt_fun warning error
* <chr> <list> <chr> <list> <chr> <chr> <chr> <list> <list> <list> <list>
1 ARM2 Placebo AGEGR1 18-64 tabulate n n 14 0 <NULL> <NULL>
2 ARM2 Placebo AGEGR1 18-64 tabulate N N 86 0 <NULL> <NULL>
3 ARM2 Placebo AGEGR1 18-64 tabulate p % 0.163 <fn> <NULL> <NULL>
4 ARM2 Placebo AGEGR1 >64 tabulate n n 72 0 <NULL> <NULL>
5 ARM2 Placebo AGEGR1 >64 tabulate N N 86 0 <NULL> <NULL>
6 ARM2 Placebo AGEGR1 >64 tabulate p % 0.837 <fn> <NULL> <NULL>
7 ARM2 Xanomeline AGEGR1 18-64 tabulate n n 19 0 <NULL> <NULL>
8 ARM2 Xanomeline AGEGR1 18-64 tabulate N N 168 0 <NULL> <NULL>
# ℹ 4 more rows
ard_summary()# An ARD data frame: 8 × 8
variable context stat_name stat_label stat fmt_fun warning error
* <chr> <chr> <chr> <chr> <list> <list> <list> <list>
1 AGE summary N N 254 0 <NULL> <NULL>
2 AGE summary mean Mean 75.1 1 <NULL> <NULL>
3 AGE summary sd SD 8.25 1 <NULL> <NULL>
4 AGE summary median Median 77 1 <NULL> <NULL>
5 AGE summary p25 Q1 70 1 <NULL> <NULL>
6 AGE summary p75 Q3 81 1 <NULL> <NULL>
7 AGE summary min Min 51 1 <NULL> <NULL>
8 AGE summary max Max 89 1 <NULL> <NULL>
ard_summary() by variableby: summary statistics are calculated by all combinations of the by variables, including unobserved factor levels
# An ARD data frame: 16 × 10
group1 group1_level variable context stat_name stat_label stat fmt_fun warning error
* <chr> <list> <chr> <chr> <chr> <chr> <list> <list> <list> <list>
1 ARM2 Placebo AGE summary N N 86 0 <NULL> <NULL>
2 ARM2 Placebo AGE summary mean Mean 75.2 1 <NULL> <NULL>
3 ARM2 Placebo AGE summary sd SD 8.59 1 <NULL> <NULL>
4 ARM2 Placebo AGE summary median Median 76 1 <NULL> <NULL>
5 ARM2 Placebo AGE summary p25 Q1 69 1 <NULL> <NULL>
6 ARM2 Placebo AGE summary p75 Q3 82 1 <NULL> <NULL>
7 ARM2 Placebo AGE summary min Min 52 1 <NULL> <NULL>
8 ARM2 Placebo AGE summary max Max 89 1 <NULL> <NULL>
# ℹ 8 more rows
ard_summary() statisticsstatistic: specify univariate summary statistics. Accepts any function, base R, from a package, or user-defined.
# An ARD data frame: 2 × 10
group1 group1_level variable context stat_name stat_label stat fmt_fun warning error
* <chr> <list> <chr> <chr> <chr> <chr> <list> <list> <list> <list>
1 ARM2 Placebo AGE summary cv cv 0.114 1 <NULL> <NULL>
2 ARM2 Xanomeline AGE summary cv cv 0.108 1 <NULL> <NULL>
ard_summary() statisticsCustomize the statistics returned for each variable
# An ARD data frame: 6 × 10
group1 group1_level variable context stat_name stat_label stat fmt_fun warning error
* <chr> <list> <chr> <chr> <chr> <chr> <list> <list> <list> <list>
1 ARM2 Placebo AGE summary cv cv 0.114 1 <NULL> <NULL>
2 ARM2 Placebo AGE2 summary mean Mean 75.2 1 <NULL> <NULL>
3 ARM2 Placebo AGE2 summary median Median 76 1 <NULL> <NULL>
4 ARM2 Xanomeline AGE summary cv cv 0.108 1 <NULL> <NULL>
5 ARM2 Xanomeline AGE2 summary mean Mean 75.0 1 <NULL> <NULL>
6 ARM2 Xanomeline AGE2 summary median Median 77 1 <NULL> <NULL>
ard_summary() fmt_funupdate_ard_fmt_fun()# An ARD data frame: 16 × 11
group1 group1_level variable context stat_name stat_label stat stat_fmt fmt_fun warning error
<chr> <list> <chr> <chr> <chr> <chr> <list> <list> <list> <list> <list>
1 ARM2 Placebo AGE summary N N 86 86 0 <NULL> <NULL>
2 ARM2 Placebo AGE summary mean Mean 75.2 75 0 <NULL> <NULL>
3 ARM2 Placebo AGE summary sd SD 8.59 8.6 1 <NULL> <NULL>
4 ARM2 Placebo AGE summary median Median 76 76.0 1 <NULL> <NULL>
5 ARM2 Placebo AGE summary p25 Q1 69 69.0 1 <NULL> <NULL>
6 ARM2 Placebo AGE summary p75 Q3 82 82.0 1 <NULL> <NULL>
7 ARM2 Placebo AGE summary min Min 52 52.0 1 <NULL> <NULL>
# ℹ 9 more rows
ard_tabulate_value(): similar to ard_tabulate(), but for dichotomous tabulations
ard_hierarchical(): similar to ard_tabulate(), but built for nested tabulations, e.g. AE terms within SOC
ard_mvsummary(): similar to ard_summary(), for multivariate summaries. The function accepts other arguments like the full and subsetted (within the by groups) data sets.
ard_missing(): tabulates rates of missingness
The results from all these functions are entirely compatible with one another, and can be stacked into a single data frame. 🥞
In addition to exporting functions to prepare summaries, {cards} exports many utilities for wrangling ARDs and creating new ARDs.
Constructing: bind_ard(), tidy_as_ard(), nest_for_ard(), check_ard_structure(), and many more
Wrangling: get_ard_statistics(), replace_null_statistic(), etc.
data and .by are shared by all ard_* calls
Additional Options .overall, .missing, .attributes, and .total_n provide even more results
By default, summaries of the .by variable are included
# An ARD data frame: 14 × 11
group1 group1_level variable variable_level context stat_name stat_label stat fmt_fun warning error
<chr> <list> <chr> <list> <chr> <chr> <chr> <list> <list> <list> <list>
1 ARM2 Placebo AGE <NULL> summary mean Mean 75.2 1 <NULL> <NULL>
2 ARM2 Placebo AGE <NULL> summary sd SD 8.59 1 <NULL> <NULL>
3 ARM2 Placebo AGEGR1 18-64 tabulate p % 0.163 <fn> <NULL> <NULL>
4 ARM2 Placebo AGEGR1 >64 tabulate p % 0.837 <fn> <NULL> <NULL>
5 ARM2 Xanomeline AGE <NULL> summary mean Mean 75.0 1 <NULL> <NULL>
6 ARM2 Xanomeline AGE <NULL> summary sd SD 8.09 1 <NULL> <NULL>
# ℹ 8 more rows
Following hierarchical summary functions aid in nested tabulations (e.g. AE terms within SOC):
ard_stack_hierarchical(): calculating nested subject-level rates
ard_stack_hierarchical_count(): calculating nested event-level counts
ard_stack_hierarchicalOur go-to function for nested subject-level rates — one call handles the whole hierarchy
A single call returns rows for both the System Organ Class (group2_level) and the nested AE term (variable_level)
id checks for duplicate rows and subsets the data along the way; denominator dictates the denominator for the rates
# An ARD data frame: 12 × 13
group1 group1_level group2 group2_level variable variable_level context stat_name stat_label stat fmt_fun warning error
<chr> <list> <chr> <list> <chr> <list> <chr> <chr> <chr> <list> <list> <list> <list>
1 TRT01A Placebo <NA> <NULL> AESOC GASTROINTESTINAL DISORDERS hierarchical n n 12 0 <NULL> <NULL>
2 TRT01A Placebo <NA> <NULL> AESOC GASTROINTESTINAL DISORDERS hierarchical N N 86 0 <NULL> <NULL>
3 TRT01A Placebo <NA> <NULL> AESOC GASTROINTESTINAL DISORDERS hierarchical p % 0.140 <fn> <NULL> <NULL>
4 TRT01A Placebo AESOC GASTROINTESTINAL DISORDERS AEDECOD DIARRHOEA hierarchical n n 9 0 <NULL> <NULL>
# ℹ 8 more rows
Displays for hierarchical data report on each level of the hierarchy (by System Organ Class, by Preferred Term)
Producing that by hand means several calls to the simpler ard_hierarchical() / ard_hierarchical_count() functions, each on a different subset of the data
ard_stack_hierarchical*() runs those calls in the background and stacks the results into one ARD
Open Exercise 1 on the workshop site and copy it into Positron/RStudio.
Compute the nested AE tabulations as described.
Report the rate of subjects who experienced fatigue.
# An ARD data frame: 117 × 13
group1 group1_level group2 group2_level variable variable_level context stat_name stat_label stat fmt_fun warning error
<chr> <list> <chr> <list> <chr> <list> <chr> <chr> <chr> <list> <list> <list> <list>
1 <NA> <NULL> <NA> <NULL> ARM Placebo tabulate n n 86 0 <NULL> <NULL>
2 <NA> <NULL> <NA> <NULL> ARM Placebo tabulate N N 254 0 <NULL> <NULL>
3 <NA> <NULL> <NA> <NULL> ARM Placebo tabulate p % 0.339 <fn> <NULL> <NULL>
4 <NA> <NULL> <NA> <NULL> ARM Xanomeline High Dose tabulate n n 84 0 <NULL> <NULL>
5 <NA> <NULL> <NA> <NULL> ARM Xanomeline High Dose tabulate N N 254 0 <NULL> <NULL>
6 <NA> <NULL> <NA> <NULL> ARM Xanomeline High Dose tabulate p % 0.331 <fn> <NULL> <NULL>
7 <NA> <NULL> <NA> <NULL> ARM Xanomeline Low Dose tabulate n n 84 0 <NULL> <NULL>
8 <NA> <NULL> <NA> <NULL> ARM Xanomeline Low Dose tabulate N N 254 0 <NULL> <NULL>
# ℹ 109 more rows
The rate of subjects with fatigue is one dplyr::filter() away.
# An ARD data frame: 9 × 14
group1 group1_level group2 group2_level variable variable_level context stat_name stat_label stat stat_fmt fmt_fun warning error
<chr> <list> <chr> <list> <chr> <list> <chr> <chr> <chr> <list> <list> <list> <list> <list>
1 ARM Placebo AESOC GENERAL DISORDERS AND ADMINISTRATION SITE CONDITIONS AEDECOD FATIGUE hierarchical n n 1 1 0 <NULL> <NULL>
2 ARM Placebo AESOC GENERAL DISORDERS AND ADMINISTRATION SITE CONDITIONS AEDECOD FATIGUE hierarchical N N 86 86 0 <NULL> <NULL>
3 ARM Placebo AESOC GENERAL DISORDERS AND ADMINISTRATION SITE CONDITIONS AEDECOD FATIGUE hierarchical p % 0.0116 1.2 <fn> <NULL> <NULL>
4 ARM Xanomeline High Dose AESOC GENERAL DISORDERS AND ADMINISTRATION SITE CONDITIONS AEDECOD FATIGUE hierarchical n n 5 5 0 <NULL> <NULL>
5 ARM Xanomeline High Dose AESOC GENERAL DISORDERS AND ADMINISTRATION SITE CONDITIONS AEDECOD FATIGUE hierarchical N N 84 84 0 <NULL> <NULL>
6 ARM Xanomeline High Dose AESOC GENERAL DISORDERS AND ADMINISTRATION SITE CONDITIONS AEDECOD FATIGUE hierarchical p % 0.0595 6.0 <fn> <NULL> <NULL>
7 ARM Xanomeline Low Dose AESOC GENERAL DISORDERS AND ADMINISTRATION SITE CONDITIONS AEDECOD FATIGUE hierarchical n n 5 5 0 <NULL> <NULL>
8 ARM Xanomeline Low Dose AESOC GENERAL DISORDERS AND ADMINISTRATION SITE CONDITIONS AEDECOD FATIGUE hierarchical N N 84 84 0 <NULL> <NULL>
9 ARM Xanomeline Low Dose AESOC GENERAL DISORDERS AND ADMINISTRATION SITE CONDITIONS AEDECOD FATIGUE hierarchical p % 0.0595 6.0 <fn> <NULL> <NULL>
Extension of the {cards} package, providing additional functions to create Analysis Results Datasets (ARDs)
The {cardx} package exports many ard_*() function for statistical methods.
We will not go into {cardx} in this workshop — just know it is there when you need statistical results in an ARD.
What happens when statistics are un-calculable?
# An ARD data frame: 2 × 10
group1 group1_level variable context stat_name stat_label stat fmt_fun warning error
* <chr> <list> <chr> <chr> <chr> <chr> <list> <list> <list> <list>
1 ARM2 Placebo AGEGR1 summary kurtosis kurtosis NA <fn> argument is not numeric or logical: returning NA non-numeric argument to binary operator
2 ARM2 Xanomeline AGEGR1 summary kurtosis kurtosis NA <fn> argument is not numeric or logical: returning NA non-numeric argument to binary operator
A Skill is a folder with a SKILL.md. The YAML description is the trigger — write it as when to use it — and the body holds the procedure, loaded on demand.
Lives at .agents/skills/ard-creation/SKILL.md. Bundle reference files (worked examples, a {cardx} catalog) beside it. Skills are portable across agents that read the .agents/ convention.
---
name: ard-creation
description: Build ARDs (Analysis Results Datasets) in R with cards and
cardx. Use whenever the user asks for an ARD, or for the numbers
behind a table -- counts, descriptive statistics, or test results.
---
# Analysis Results Datasets
Setup: load cards; add cardx and broom as needed.
## Decision order
1. {cards} ard_*() first -- counting and tabulating, univariate
summaries, some multivariable summaries, and more:
ard_tabulate(), ard_summary(), ard_mvsummary(), ard_missing();
stack with ard_stack(). For nested tabulations use the stack
versions ard_stack_hierarchical*(), not ard_hierarchical*()
2. statistical methods and tests -> {cardx}, e.g.
ard_stats_t_test(), ard_regression()
3. method not in {cardx} -> wrap broom::tidy() and convert to ARD,
e.g. \(x) t.test(x) |> broom::tidy() inside ard_summary()
4. no tidy method -> build the ARD brute force with dplyr, nest_for_ard(), bind_ard()
Always check_ard_structure() the result.Compute once, reuse everywhere — one ARD feeds any number of tables and figures
QC the values — numeric or rounded/formatted…or both!
Composable by design — every ard_*() result stacks into one tidy data frame 🥞
The engine for the ARS — {cards} powers a metadata-driven, reproducible analysis workflow
ARDs are the foundation. Next, we turn these results into tables. ➡️
Began to address reproducibility issues while working in academia
Goal to build a package to summarize study results with code that was both simple and customizable
The stats
Won the 2025 Brian Bole Award of Excellence from R in Pharma
Won the 2021 American Statistical Association (ASA) Innovation in Programming Award
Won the 2024 Posit Pharma Table Contest





Create tabular summaries with sensible defaults but highly customizable.
For our workshop, we will focus on the following summary types as well as themes and print engines.
tbl_summary()
tbl_hierarchical()
Other helpful functions we’re not covering:
tbl_hierarchical_count(): similar to tbl_hierarchical() for counts instead of rates
tbl_cross(): cross tabulations
tbl_continuous(): summarizing continuous variables by 2 categorical variables
tbl_wide_summary(): similar to tbl_summary() but statistics are presented in separate columns
regression models, survival data, survey data, and many more!
Four types of summaries: continuous, continuous2, categorical, and dichotomous
Statistics are median (IQR) for continuous, n (%) for categorical/dichotomous
Variables coded 0/1, TRUE/FALSE, Yes/No treated as dichotomous by default
Label attributes are printed automatically
| Characteristic | Placebo N = 861 |
Xanomeline N = 1681 |
|---|---|---|
| Age, years | ||
| Mean (SD) | 75 (8.6) | 75 (8.1) |
| Min, Max | 52, 89 | 51, 88 |
| Ethnicity | ||
| HISPANIC OR LATINO | 3 (3.5%) | 9 (5.4%) |
| NOT HISPANIC OR LATINO | 83 (97%) | 159 (95%) |
| Female | 53 / 86 (62%) | 90 / 168 (54%) |
| 1 n (%); n / N (%) | ||
by: specify a column variable for cross-tabulation
type: specify the summary type
statistic: customize the reported statistics
label: change or customize variable labels
digits: specify the number of decimal places for rounding
Named list are OK too! label = list(age = "Patient Age")
Use the following helpers to select groups of variables: all_continuous(), all_categorical()
Use all_stat_cols() to select the summary statistic columns
tbl_summary() objects can also be updated using related functions.
add_*() add additional column of statistics or information, e.g. p-values, q-values, overall statistics, treatment differences, N obs., and more
modify_*() modify table headers, spanning headers, footnotes, and more
| Characteristic | Placebo N = 861 |
Xanomeline N = 1681 |
Overall N = 2541 |
|---|---|---|---|
| Age | 76 (69, 82) | 77 (71, 81) | 77 (70, 81) |
| Ethnicity | |||
| HISPANIC OR LATINO | 3 (3.5%) | 9 (5.4%) | 12 (4.7%) |
| NOT HISPANIC OR LATINO | 83 (97%) | 159 (95%) | 242 (95%) |
| Female | 53 (62%) | 90 (54%) | 143 (56%) |
| 1 Median (Q1, Q3); n (%) | |||
add_overall(): adds a column of overall statistics| Characteristic | N | Placebo N = 961 |
Xanomeline N = 1771 |
p-value2 |
|---|---|---|---|---|
| Tumor Size, mm | 25 | 78 (38, 96) | 67 (26, 92) | 0.659 |
| Progression | 273 | 6 (6.3%) | 9 (5.1%) | 0.687 |
| 1 Median (Q1, Q3); n (%) | ||||
| 2 Wilcoxon rank sum exact test; Pearson’s Chi-squared test | ||||
add_n(): adds a column non-missing counts
add_p(): adds a column of p-values
add_difference(): mean and rate differences between two groups. Can also be adjusted differences
add_stat(): add any statistic you like, placed on the label or the level rows, in a single column or many
And many more!
tbl <-
adsl |>
tbl_summary(by = ARM2, include = c("AGE", "ETHNIC", "FEMALE")) |>
modify_header(
stat_1 ~ "**Group A**",
stat_2 ~ "**Group B**"
) |>
modify_spanning_header(
all_stat_cols() ~ "**Drug**") |>
modify_footnote_header(
footnote = paste("median (IQR) for continuous;",
"n (%) for categorical"),
columns = all_stat_cols()
)
tbl| Characteristic |
Drug
|
|
|---|---|---|
| Group A1 | Group B1 | |
| Age | 76 (69, 82) | 77 (71, 81) |
| Ethnicity | ||
| HISPANIC OR LATINO | 3 (3.5%) | 9 (5.4%) |
| NOT HISPANIC OR LATINO | 83 (97%) | 159 (95%) |
| Female | 53 (62%) | 90 (54%) |
| 1 median (IQR) for continuous; n (%) for categorical | ||
Use show_header_names() to see the header names available to modify_header():
Column Name Header level* N* n* p*
label "**Characteristic**" 254 <int>
stat_1 "**Group A**" Placebo <chr> 254 <int> 86 <int> 0.339 <dbl>
stat_2 "**Group B**" Xanomeline <chr> 254 <int> 168 <int> 0.661 <dbl>
* These values may be dynamically placed into headers (and other locations).
ℹ Review the `modify_header()` (`?gtsummary::modify_header()`) help for examples.
Use tbl_hierarchical() and tbl_hierarchical_count() for reporting of AEs, Con Meds, and more.
| Primary System Organ Class Dictionary-Derived Term |
Placebo N = 861 |
Xanomeline N = 1681 |
|---|---|---|
| CARDIAC DISORDERS | 4 (4.7%) | 8 (4.8%) |
| ATRIAL FLUTTER | 0 (0%) | 2 (1.2%) |
| MYOCARDIAL INFARCTION | 4 (4.7%) | 6 (3.6%) |
| EYE DISORDERS | 1 (1.2%) | 0 (0%) |
| EYE ALLERGY | 1 (1.2%) | 0 (0%) |
| EYE SWELLING | 1 (1.2%) | 0 (0%) |
| 1 n (%) | ||
The same add_*() and modify_*() verbs work here too, e.g. add_overall(last = TRUE) and add_difference() for risk differences between arms.
| Primary System Organ Class Dictionary-Derived Term |
Placebo N = 861 |
Xanomeline N = 1681 |
|---|---|---|
| CARDIAC DISORDERS | 4 (4.7%) | 8 (4.8%) |
| MYOCARDIAL INFARCTION | 4 (4.7%) | 6 (3.6%) |
| 1 n (%) | ||
sort_hierarchical(): orders rows by descending event frequency (default). Use sort = everything() ~ "alphanumeric" to sort alphabetically instead
filter_hierarchical(): keeps AEs meeting an occurrence threshold. The filter is an expression on the underlying counts, e.g. sum(n) >= 5, n_overall > 20, or p_overall > 0.05
tbl_n <-
tbl_summary(adsl, include = ETHNIC, statistic = ETHNIC ~ "{n}") |>
modify_header(all_stat_cols() ~ "**N**") |> # update column header
remove_footnote_header() # remove footnote
tbl_age <-
tbl_continuous(adsl, include = ETHNIC, variable = AGE, by = ARM2) |>
modify_header(all_stat_cols() ~ "**{level}**") # update header
# combine the tables side by side
list(tbl_n, tbl_age) |>
tbl_merge(tab_spanner = FALSE) # suppress default header| Characteristic | N | Placebo1 | Xanomeline1 |
|---|---|---|---|
| Ethnicity | |||
| HISPANIC OR LATINO | 12 | 64 (63, 86) | 63 (56, 78) |
| NOT HISPANIC OR LATINO | 242 | 76 (70, 82) | 77 (71, 81) |
| 1 Age: Median (Q1, Q3) | |||
tbl_drug_a <- filter(adsl, ARM2 == "Placebo") |>
tbl_summary(include = ETHNIC)
tbl_drug_b <- filter(adsl, ARM2 == "Xanomeline") |>
tbl_summary(include = ETHNIC)
# stack the two tables
list(tbl_drug_a, tbl_drug_b) |>
tbl_stack(group_header = c("Placebo", "Xanomeline"), quiet = TRUE) |> # optionally include headers for each table
modify_header(all_stat_cols() ~ "**Summary Statistics**")| Characteristic | Summary Statistics1 |
|---|---|
| Placebo | |
| Ethnicity | |
| HISPANIC OR LATINO | 3 (3.5%) |
| NOT HISPANIC OR LATINO | 83 (97%) |
| Xanomeline | |
| Ethnicity | |
| HISPANIC OR LATINO | 9 (5.4%) |
| NOT HISPANIC OR LATINO | 159 (95%) |
| 1 n (%) | |
Covered so far: Analysis Results Datasets · {gtsummary} fundamentals
ARD-first tables — every cell traceable, and QC by comparing one ARD against another.
Print engines — {gt}, {flextable}, and submission-ready Word output.
Adopting {gtsummary} — a house theme, wrappers of your own, and {crane}.
Coding agents — writing your standards down so an agent follows them without being reminded.
ARDs are the backbone for all calculations in gtsummary
Every gtsummary table saves the ARDs from each calculation
$tbl_summary
# An ARD data frame: 27 × 12
group1 group1_level variable variable_level context stat_name stat_label stat fmt_fun warning error gts_column
<chr> <list> <chr> <list> <chr> <chr> <chr> <list> <list> <list> <list> <chr>
1 ARM2 Placebo AGE <NULL> summary median Median 76 <fn> <NULL> <NULL> stat_1
2 ARM2 Placebo AGE <NULL> summary p25 Q1 69 <fn> <NULL> <NULL> stat_1
3 ARM2 Placebo AGE <NULL> summary p75 Q3 82 <fn> <NULL> <NULL> stat_1
4 ARM2 Xanomeline AGE <NULL> summary median Median 77 <fn> <NULL> <NULL> stat_2
5 ARM2 Xanomeline AGE <NULL> summary p25 Q1 71 <fn> <NULL> <NULL> stat_2
6 ARM2 Xanomeline AGE <NULL> summary p75 Q3 81 <fn> <NULL> <NULL> stat_2
7 <NA> <NULL> AGE <NULL> attribu… label Variable … Age <fn> <NULL> <NULL> <NA>
8 <NA> <NULL> AGE <NULL> attribu… class Variable … numeric <NULL> <NULL> <NULL> <NA>
9 <NA> <NULL> ARM2 <NULL> attribu… label Variable … ARM2 <fn> <NULL> <NULL> <NA>
10 <NA> <NULL> ARM2 <NULL> attribu… class Variable … character <NULL> <NULL> <NULL> <NA>
# ℹ 17 more rows
ARDs are wonderful for QCing {gtsummary} tables. 😻
ARDs include the formatted and un-formatted numbers that appear in the table.
Extract the ARD from the {gtsummary} table.
Build fresh ARD from source data, and compare it to the ARD from the table.
compare_ard()We built ard_table with gather_ard(), and ard_source independently from the source data with cards::ard_stack(). compare_ard() lines the two up on their key columns and reports any rows that differ — here, both the raw stat and the formatted stat_fmt.
The comparison `keys` are "group1", "group1_level", "variable", "variable_level", and "stat_name".
The comparison columns are "stat" and "stat_fmt".
── Mis-matched Rows ────────────────────────────────────────────────────────────────────────────────────────────────────
✔ No rows in `x` that do not appear in `y`.
✔ No rows in `y` that do not appear in `x`.
── Comparison Results ──────────────────────────────────────────────────────────────────────────────────────────────────
✔ No differences found in column "stat".
✔ No differences found in column "stat_fmt".
Similar to functions that accept a data frame, the package exports functions with nearly identical APIs that accept an ARD.
We can use the skills we learned earlier today to create ARDs for gtsummary tables, then pass the ARD to tbl_ard_summary().
| Characteristic | Overall1 |
|---|---|
| Age | 77.0 (70.0, 81.0) |
| Ethnicity | |
| HISPANIC OR LATINO | 12 (4.7%) |
| NOT HISPANIC OR LATINO | 242 (95.3%) |
| Female | 143 (56.3%) |
| 1 Median (Q1, Q3); n (%) | |
adsl |>
labelled::set_variable_labels(AGE = "Age, years") |>
ard_stack(
.by = ARM2,
ard_tabulate(variables = ETHNIC),
# add these for best-looking tables
.attributes = TRUE,
.missing = TRUE
) |>
cards::update_ard_fmt_fun(stat_names = c("n", "p"), fmt_fun = \(x) "xx") |>
tbl_ard_summary(
by = ARM2,
type = all_continuous() ~ "continuous2",
statistic = all_continuous() ~ c("{mean} ({sd})", "{min} - {max}"),
missing = "no"
) |>
modify_header(all_stat_cols() ~ "**{level}** \nN = xx")| Characteristic | Placebo N = xx1 |
Xanomeline N = xx1 |
|---|---|---|
| Ethnicity | ||
| HISPANIC OR LATINO | xx (xx%) | xx (xx%) |
| NOT HISPANIC OR LATINO | xx (xx%) | xx (xx%) |
| 1 n (%) | ||
save_flex_docx() writes any {gtsummary} (or {flextable}) table to a submission-ready Word document in a single step.
Report headers & footers — we can now place customized output into the header and footer areas of the word document, and place field codes (e.g. Page X of Y)
Source notes & footnotes — by default, footnotes, source notes, and abbreviations are placed in the Word document’s footer area
Templates & page setup — supply a Word template= or pr_section= to control page size, margins, and landscape orientation
The AE table on the next slide is built with a custom header_ft, three source notes (the last carries the git SHA), and saved to the repo — then converted to PDF.
save_flex_docx() outputThree moves, in order.
Your house defaults — rounding, headers, print engine — applied to every table, without anyone passing an argument.
Named functions for the tables your team builds over and over, so the shape of a table is written down once.
Adoption becomes a single library() call, and the standard is versioned and testable.
A theme is set once, before you build any tables, and every table in the session picks it up.
theme_gtsummary_journal("jama") — defaults matching a journal’s guidelines
theme_gtsummary_compact() — smaller font and cell padding
theme_gtsummary_language("es") — translate every table
theme_gtsummary_printer(print_engine = "flextable") — change the print engine
Themes stack: call several, and each one layers onto the last.
Every element is named "<function>-<input type>:<description>".
"pkgwide-str:print_engine" — applies package-wide, takes a string
"tbl_summary-fn:percent_fun" — applies to tbl_summary(), takes a function
Elements come in two flavors:
Internal behavior you cannot reach with a function argument.
Argument defaults — anything named *-arg:* sets that argument’s default.
Full catalog of theme elements: {gtsummary} themes vignette
Open Exercise 2 on the workshop site and copy it into Positron/RStudio.
Fill in the four theme elements, then check and set your theme.
Ship it as a function, the same way {gtsummary} and {crane} do — and start from a theme that already exists.
theme_gtsummary_rpharma <- function(set_theme = TRUE) {
lst_theme <-
utils::modifyList(
# start from the shipped compact theme, then layer ours on top
theme_gtsummary_compact(set_theme = FALSE),
list(
"pkgwide-str:theme_name" = "R/Pharma 2026",
"pkgwide-str:print_engine" = "flextable",
"tbl_summary-fn:percent_fun" = scales::label_number(scale = 100, accuracy = 0.1),
"pkgwide-fn:pvalue_fun" = scales::label_pvalue(),
"tbl_summary-str:header-withby" = "**{level}** \n(N = {n})"
)
)
if (set_theme == TRUE) set_gtsummary_theme(lst_theme)
invisible(lst_theme)
}Package defaults · printed with {gt}
| Characteristic | Placebo N = 861 |
Xanomeline N = 1681 |
p-value2 |
|---|---|---|---|
| Age | 76 (69, 82) | 77 (71, 81) | 0.8 |
| Ethnicity | 0.8 | ||
| HISPANIC OR LATINO | 3 (3.5%) | 9 (5.4%) | |
| NOT HISPANIC OR LATINO | 83 (97%) | 159 (95%) | |
| 1 Median (Q1, Q3); n (%) | |||
| 2 Wilcoxon rank sum test; Fisher’s exact test | |||
After theme_gtsummary_rpharma() · printed with {flextable}
Characteristic | Placebo | Xanomeline | p-value2 |
|---|---|---|---|
Age | 76 (69, 82) | 77 (71, 81) | 0.816 |
Ethnicity | 0.756 | ||
HISPANIC OR LATINO | 3 (3.5%) | 9 (5.4%) | |
NOT HISPANIC OR LATINO | 83 (96.5%) | 159 (94.6%) | |
1Median (Q1, Q3); n (%) | |||
2Wilcoxon rank sum test; Fisher's exact test | |||
Consistency without memorization. Nobody has to remember that percentages go to one decimal place — the theme already knows.
One place to change. When the standard changes, edit the theme and re-render. You are not grepping for digits = across forty programs.
Better reviews. If formatting is handled, review comments are about the numbers instead of the decimal places.
A shorter first day. New team members inherit the house style before they have written a line of code.
Portable. Put the theme function in your company package and adoption is one library() call.
A theme changes defaults — everywhere, all at once, for functions that already exist.
A wrapper earns its place when you want something a default cannot give you:
a name your team already says out loud — “the demography table”
documentation and tests that live with the function
a table built from several {gtsummary} calls, not one
defaults that apply to this table only, not the whole session
Open Exercise 3 on the workshop site and copy it into Positron/RStudio.
Finish tbl_pharma_summary(), then use it to summarize AGE and ETHNIC by ARM2.
tbl_pharma_summary()A thin wrapper: multi-line continuous summaries and our house statistics, with everything still overridable.
tbl_pharma_summary <- function(
data,
by = NULL,
type = NULL,
statistic = list(
all_continuous() ~ c("{mean} ({sd})", "{median}",
"{p25}, {p75}", "{min}, {max}"),
all_categorical() ~ "{n} ({p}%)"
),
...) {
with_gtsummary_theme(
list("tbl_summary-str:default_con_type" = "continuous2"),
tbl_summary(
data = data,
by = {{ by }},
type = type,
statistic = statistic,
...
)
)
}tbl_pharma_summary() in actionAn explicit argument still beats the theme element, so nothing is taken away from the caller.

We just wrote a one-function version of this. {crane} is the same idea, maintained as a real package — Roche’s theme and wrappers, on CRAN.
Continuous variables default to continuous2.
tbl_summary(missing*) arguments have been changed to tbl_roche_summary(nonmissing*).
Counts represented by 0 (0%) print as 0.

| Placebo (N = 86) |
Xanomeline (N = 168) |
|
|---|---|---|
| Age | ||
| n | 86 | 168 |
| Mean (SD) | 75.2 (8.6) | 75.0 (8.1) |
| Median | 76.0 | 77.0 |
| Min - Max | 52 - 89 | 51 - 88 |
| ETHNIC | ||
| n | 86 | 168 |
| HISPANIC OR LATINO | 3 (3.5%) | 9 (5.4%) |
| NOT HISPANIC OR LATINO | 83 (96.5%) | 159 (94.6%) |
| REFUSED | 0 | 0 |

Lab values are summarized by visit and include the change from baseline.
This is a simple table that is just a tbl_merge() of the AVAL summary and the CHG summary.
But the general structure appears enough times in our catalog, we make it simple for our programmers to create.








Our theme is implemented in crane::theme_gtsummary_roche(). Primary changes include a custom function for rounding percentages, p-values rounded to four decimal places, headers that default to bold and include N in parentheses (e.g. 'Placebo \n (N = 184)'), and printing all tables with {flextable} using Roche-specific styling — font, font size, borders, cell padding, etc.
Same named list, same set_theme argument — just with style_roche_percent() and style_roche_pvalue() in the function slots, and eleven elements instead of five.
Placebo | Xanomeline | |
|---|---|---|
Age | ||
n | 86 | 168 |
Mean (SD) | 75.2 (8.6) | 75.0 (8.1) |
Median | 76.0 | 77.0 |
Min - Max | 52 - 89 | 51 - 88 |
ETHNIC | ||
n | 86 | 168 |
HISPANIC OR LATINO | 3 (3.5%) | 9 (5.4%) |
NOT HISPANIC OR LATINO | 83 (96.5%) | 159 (94.6%) |
REFUSED | 0 | 0 |

The cardinal initiative is an industry collaborative effort under the pharmaverse.
The site includes examples for building ARDs and tables from the FDA Standard Safety Tables and Figures Integrated Guide using {cards} and {gtsummary}.
Earlier we pasted a prompt into a chat LLM and copied code back.
A coding agent (e.g. Claude Code) instead lives inside your project: it reads your files, runs R, and iterates — and it follows instructions you commit to the repo.
The goal: a coding partner that adheres to your domain standards without constant reminders.
Give the agent an explicit priority order for building a table:
Set the theme — crane::theme_gtsummary_roche() after loading packages.
Prefer {crane} wrappers — tbl_roche_summary(), tbl_baseline_chg(), … when one fits.
Fall back to {gtsummary} — tbl_summary(), tbl_hierarchical(), …
ARD-first for complex/custom — build ARDs with {cards}/{cardx}, render with gtsummary::tbl_ard_*().
Last resort — build the ARD yourself, then gtsummary::as_gtsummary().
The golden path above, encoded. Lives at .agents/skills/gtsummary-tables/SKILL.md, with reference files (a crane catalog, worked examples) bundled beside it.
---
name: gtsummary-tables
description: Build clinical summary tables in R with crane, gtsummary,
and cards. Use whenever the user asks for a summary / demographics /
AE / lab table or a "Table 1".
---
# Clinical summary tables
Setup: load tidyverse, crane, gtsummary, cards; then
crane::theme_gtsummary_roche().
## Decision order
1. {crane} wrapper (tbl_roche_summary(), tbl_baseline_chg()) if one fits
2. else {gtsummary} tbl_*() (tbl_summary(), tbl_hierarchical())
3. complex/custom -> ARD-first: cards::ard_stack() / cardx::ard_*()
-> tbl_ard_*()
4. last resort -> build the ARD, then as_gtsummary()
Never use gtsummary::tbl_custom_summary(): it is not ARD-based.
Always gather_ard() the result for QC.One consistent set of tbl_*() verbs — from a demographics table to AE and lab tables.
Every cell is traceable and QC-ready via gather_ard() — submission-ready by construction.
A shared theme and reusable wrappers capture your house standards; a coding agent applies them without reminders.
Docs & cheat sheet — danieldsjoberg.com/gtsummary
The full function reference, and a detailed tbl_summary() vignette
{cardinal} — FDA safety-table catalog
Ask on stackoverflow.com — use the gtsummary tag. Just kidding — nobody uses Stack Overflow anymore; ask an AI.

2,300,000+ installations — go build something cute.