Creating Submission-Ready Tables in R with rtables

Introduction

We’ve already explored gt and gtsummary for quick summaries and polished tables. Now, let’s look at rtables — a powerful package from the pharmaverse ecosystem, built for creating complex, customizable tables used in clinical trial and statistical reporting. It’s especially handy when you need full control over table layout, nested groups, and precise summaries.

Let us begin with installing and loading the required package

install.packages("rtables")
library(rtables)

About the dataset

Variable

Description

Patient_ID

Unique subject identifier

Treatment

Treatment arm assigned to the subject

Center

Clinical center/site identifier

Day

Day of assessment (e.g., 1, 7, 14 for longitudinal follow-up)

VAS

Visual Analogue Scale score (e.g., pain score 0–100)

Common steps for any rtables creation

  1. basic_table()
    Initializes the layout of a table. It sets the foundation on which all other specifications are built.

  2. split_cols_by() and split_rows_by()
    Define how the dataset should be split across columns and rows, respectively. For example, you might split columns by treatment arm and rows by a categorical variable such as severity or system organ class.

  3. analyze()
    Specifies what analysis or summary statistics should be computed for each group defined by the splits. Common summaries include means, counts, proportions, or custom functions.

  4. build_table()
    Executes the layout and applies it to a dataset. This step returns the final table object that can be printed or exported.

# STEP 1: Pre-processing – Ensure Treatment is a factor (required by rtables for column splitting)
vas_df$Treatment <- as.factor(vas_df$Treatment)
vas_df$Day <- as.factor(vas_df$Day)

# STEP 2: Capture column levels used by rtables (to ensure alignment)
col_levels <- levels(vas_df$Treatment)

# STEP 3: Create column counts based on unique Patient_IDs per Treatment group
# To display unique patients per group, not row count.
PID_byTreatment <- vas_df %>%
  group_by(Treatment) %>%
  summarise(n = n_distinct(Patient_ID)) %>%
  deframe()  # Converts to named vector for col_counts argument

# STEP 4: Reorder the named vector 
# Always match the order of the names in col_counts to the factor levels used by split_cols_by() to prevent alignment issues.
PID_byTreatment <- PID_byTreatment[col_levels]  # Ensures proper alignment in table header


# STEP 5: Create custom summary function
vas_stats <- function(x) {
  list(
    "Mean" = round(mean(x, na.rm = TRUE), 2),
    "SD"   = round(sd(x, na.rm = TRUE), 2),
    "Min"  = round(min(x, na.rm = TRUE), 2),
    "Max"  = round(max(x, na.rm = TRUE), 2)
  )
}


# STEP 6: Define the table layout
lyt <- basic_table(title = "VAS Summary by Treatment and Day") %>%
  split_cols_by("Treatment") %>%
  split_rows_by("Day", split_label = "Study Day", child_labels = "visible") %>%
  analyze("VAS", afun = vas_stats, var_labels = "", show_labels = "visible") %>%
  append_topleft("Days")

# STEP 7: Build the table using the dataset and the column counts
rtbl <- build_table(lyt, vas_df, col_counts = PID_byTreatment)

# Display the resulting table
rtbl
VAS Summary by Treatment and Day

————————————————————————————————————
           Drug A   Drug B   Placebo
Days       (N=11)   (N=11)    (N=8) 
————————————————————————————————————
1                                   
                                    
    Mean    46.8    46.97     54.88 
    SD     28.01     28.6     25.35 
    Min     17.5     14.2     18.4  
    Max     94.8     98.5     98.4  
7                                   
                                    
    Mean   55.15    51.37     57.86 
    SD     21.28    30.07     32.64 
    Min     13.1     7.7      15.4  
    Max     84.7     89.3      98   
14                                  
                                    
    Mean     38     52.68     35.34 
    SD     27.38    26.77     27.95 
    Min     6.1      11.1       1   
    Max     95.4     91.4     82.2  

Conclusion: Choosing between rtables and gtsummary

Both rtables and gtsummary serve valuable purposes, but they are optimized for different end goals:

Use rtables when:

  • You’re working on clinical trial submissions (e.g., FDA/EMA).
  • You need summary tables broken down by visit, treatment arm, or subgroup.
  • Your tables require nested grouping, such as Treatment → Visit → Adverse Event.
  • You need precise control over N counts, column structure, and formatting.
  • You’re preparing production-grade tables for regulatory review or internal clinical teams.

Use gtsummary when:

  • You need quick exploratory summaries during data analysis.
  • You’re preparing tables for manuscripts, slides, or posters.
  • You want to include simple statistical comparisons (e.g., t-tests, chi-square).
  • You value tidyverse compatibility and integration with gt or flextable for polished outputs.

💡 Analogy:
Think of gtsummary as a presentation tool for data, and rtables as a regulatory tool built for submission-critical reporting.