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.
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 rtableslibrary(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)
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.
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 →