Clinical TLFs in R: gtsummary and rtables Side by Side

clinical
cdisc
biostatistics
Produce clinical trial tables in R two ways: a demography Table 1 with gtsummary and an adverse-event summary with rtables — the pharmaverse package built for shell-faithful CSR layouts.
Author

Rverse Analytics

Published

August 8, 2026

Tables, listings and figures are where a clinical study report is won or lost — and where statistical programming hours actually go. R now has two mature roads to a shell-faithful table: gtsummary, the fastest route to a beautiful Table 1, and rtables, the Roche-built pharmaverse engine designed around CSR layouts (nested row splits, exact column structures, RTF delivery). A working biostatistician should hold both.

Road 1 — Demography Table 1 with gtsummary

library(gtsummary)

trial |>
  select(trt, age, marker, stage, grade) |>
  tbl_summary(
    by = trt,
    statistic = list(all_continuous() ~ "{mean} ({sd})"),
    missing_text = "Missing"
  ) |>
  add_overall() |>
  add_p() |>
  modify_header(label ~ "**Characteristic**") |>
  bold_labels()
Characteristic Overall
N = 2001
Drug A
N = 981
Drug B
N = 1021
p-value2
Age 47 (14) 47 (15) 47 (14) 0.7
    Missing 11 7 4
Marker Level (ng/mL) 0.92 (0.86) 1.02 (0.89) 0.82 (0.83) 0.085
    Missing 10 6 4
T Stage


0.9
    T1 53 (27%) 28 (29%) 25 (25%)
    T2 54 (27%) 25 (26%) 29 (28%)
    T3 43 (22%) 22 (22%) 21 (21%)
    T4 50 (25%) 23 (23%) 27 (26%)
Grade


0.9
    I 68 (34%) 35 (36%) 33 (32%)
    II 68 (34%) 32 (33%) 36 (35%)
    III 64 (32%) 31 (32%) 33 (32%)
1 Mean (SD); n (%)
2 Wilcoxon rank sum test; Pearson’s Chi-squared test

Five functions, publication-ready, and as_flex_table() exports it straight into the Word shell your sponsor expects. This is the road for baseline tables, regression summaries and most academic deliverables — we walk through it in the Table 1 video lesson.

Road 2 — AE summary with rtables

Adverse-event tables are structurally different: subjects counted once per system organ class and preferred term, “n (%)” against the safety population, nested rows. This is rtables territory. A tiny synthetic ADAE:

library(rtables)
library(tern)      # clinical analyze functions on top of rtables
library(dplyr)

adsl <- tibble(
  USUBJID = sprintf("01-%03d", 1:60),
  ARM     = rep(c("Drug A", "Placebo"), each = 30)
)
set.seed(42)
adae <- tibble(
  USUBJID = sample(adsl$USUBJID, 90, replace = TRUE),
  AESOC   = sample(c("Gastrointestinal disorders", "Nervous system disorders",
                     "Infections and infestations"), 90, replace = TRUE),
  AEDECOD = sample(c("Nausea", "Headache", "Dizziness", "Nasopharyngitis",
                     "Diarrhoea"), 90, replace = TRUE)
) |>
  left_join(adsl, by = "USUBJID")

lyt <- basic_table(show_colcounts = TRUE) |>
  split_cols_by("ARM") |>
  split_rows_by("AESOC", split_fun = drop_split_levels) |>
  summarize_num_patients(var = "USUBJID",
                         .stats = "unique",
                         .labels = c(unique = "Patients with >=1 event")) |>
  count_occurrences(vars = "AEDECOD")

build_table(lyt, adae, alt_counts_df = adsl)
                           Placebo       Drug A  
                            (N=30)       (N=30)  
—————————————————————————————————————————————————
Patients with >=1 event   10 (33.3%)   15 (50.0%)
  Diarrhoea                1 (3.3%)    3 (10.0%) 
  Dizziness                2 (6.7%)     2 (6.7%) 
  Headache                3 (10.0%)     1 (3.3%) 
  Nasopharyngitis         5 (16.7%)    5 (16.7%) 
  Nausea                   2 (6.7%)    6 (20.0%) 
Patients with >=1 event   19 (63.3%)   10 (33.3%)
  Diarrhoea               4 (13.3%)    4 (13.3%) 
  Dizziness               5 (16.7%)    3 (10.0%) 
  Headache                9 (30.0%)        0     
  Nasopharyngitis          2 (6.7%)    3 (10.0%) 
  Nausea                  6 (20.0%)     2 (6.7%) 
Patients with >=1 event   5 (16.7%)    11 (36.7%)
  Diarrhoea                1 (3.3%)    4 (13.3%) 
  Dizziness                1 (3.3%)     1 (3.3%) 
  Headache                 2 (6.7%)     1 (3.3%) 
  Nasopharyngitis             0         2 (6.7%) 
  Nausea                   1 (3.3%)    4 (13.3%) 

Read the layout like a sentence: split columns by arm, split rows by SOC, count unique patients, then count occurrences by preferred term. The layout object (lyt) is separate from the data — which means the same shell definition is reusable across studies, and your QC programmer can review the layout as a specification.

Which road when?

Situation Reach for
Baseline characteristics, regression tables, academic papers gtsummary
AE/exposure/lab tables against a CSR shell, RTF batch delivery rtables
Denominators from a different dataset than the counts rtables (alt_counts_df)
One-off table needed in ten minutes gtsummary

The honest answer for a CRO deliverable is usually both in the same pipeline — gtsummary where it is faster, rtables where the shell demands structure. The inputs come from ADaM datasets (derived with admiral), and both roads end in the same place: a table that matches the mock shell to the cell.


Full shell-to-output pairs live in our TLF gallery. Need a TLF package produced or double-programmed? That’s what we do →