Presentation-Ready Analytic Result Tables Using gtsummary

Table of Contents

⚠️ Make Sure You Understand the Code Before Using It ⚠️

Load Package and Data

library(tidyverse)
library(gtsummary)
library(survival)
data = trial %>% # build in sample dataset in gtsummary package
  mutate(trt = factor(trt))

Table 1 with Added P-Value Columns (Parametric, Non-Parametric, and GLM)

Basic table

tbl1 <- data %>%
  select(age, grade, trt) %>% # select outcome and variables you want to include in order
  tbl_summary(
    by = trt, 
    type = all_continuous() ~ "continuous2", # update the continuous variables to summarize on multiple lines, default is median(Q1,Q3)
    statistic = all_continuous() ~ c(
      "{N_nonmiss}",
      "{median} ({p25}, {p75})",
      "{min}, {max}"
    ),
    missing = "ifany", # missing = "no", whether to display a row with the number of missing observations
    missing_text = "(Missing)", # text label for the missing number row
    missing_stat = "{N_miss}/{N_obs} ({p_miss})%)", # stats of missing data
    digits = list(all_categorical() ~ c(0, 1), all_continuous2() ~ c(0, 2, 2)), # digits of stats, can simply be ~1
    percent = "column") %>% # Default is columnwise percentage, could be set as , percent = "row"
  add_p(
    list(all_continuous() ~ "t.test", all_categorical() ~ "chisq.test"),
    pvalue_fun = function(x) style_pvalue(x, digits = 3)
    ) %>% 
  # add header to p.value
  modify_header(p.value ~ "**Parametric p-value**")

## To check the header names
# show_header_names(tbl1)
tbl1 %>% 
  as_kable_extra()

Characteristic

Drug A
N = 98

Drug B
N = 102

Parametric p-value

Age

0.834

N Non-missing

91

98

Median (Q1, Q3)

46.00 (37.00, 60)

48.00 (39.00, 56)

Min, Max

6.00, 78.00

9.00, 83.00

(Missing)

7/98 (7.1)%)

4/102 (3.9)%)

Grade

0.871

I

35 (35.7%)

33 (32.4%)

II

32 (32.7%)

36 (35.3%)

III

31 (31.6%)

33 (32.4%)

1 n (%)

2 Welch Two Sample t-test; Pearson’s Chi-squared test

Extract Non-parametric p-value

tbl2 <- data  %>% 
  select(age, grade, trt) %>% 
  tbl_summary(by = trt) %>%
  add_p(
    list(all_continuous() ~ "wilcox.test", all_categorical() ~ "fisher.test"),
    pvalue_fun = function(x) style_pvalue(x, digits = 3)
    ) %>% 
  # hide all columns, except p-value
  modify_column_hide(-p.value) %>%
  # add header to p.value
  modify_header(p.value ~ "**Non-Parametric p-value**")

Extract regression model p-value

tbl3 <- data %>%
  select(age, grade, marker, trt) %>%
  tbl_uvregression(
    y = trt, # y should be a factor
    method = glm, # specify the regression function
    method.args = list(family = binomial), # specify the outcome type
    # adjust all models for marker level
    formula = "{y} ~ {x} + marker", # remove "marker" to obtain regular univariable models
    exponentiate = TRUE, 
    pvalue_fun = function(x) style_pvalue(x, digits = 3)
  ) %>%
  add_global_p(keep = FALSE) %>% # adds the global p-value for categorical variables, use car::Anova() by default,  # Likelihood ratio test (optional): Anova(fit, test = "LR")
  # hide all columns, except p-value
  modify_column_hide(-p.value) %>%
  # add header to p.value
  modify_header(p.value ~ "**Adjusted GLM p-value**") %>%
  modify_footnote(p.value ~ "Logistic regression adjusted for marker level")
## Warning: Use of the "ci" column was deprecated in gtsummary v2.0, and the column will
## eventually be removed from the tables.
## ! Review `?deprecated_ci_column()` for details on how to update your code.
## ℹ The "ci" column has been replaced by the merged "conf.low" and "conf.high"
##   columns (merged with `modify_column_merge()`).
## ℹ In most cases, a simple update from `ci = 'a new label'` to `conf.low = 'a
##   new label'` is sufficient.

Merge basic table and additional columns

tbl_merge(list(tbl1, tbl2, tbl3)) %>%
  modify_spanning_header(everything() ~ NA) %>% 
  remove_footnote_header(columns = all_stat_cols()) %>% 
  modify_abbreviation(c("Adding abbreviation on top of the existing ones, the function will reorder them by alphabetical"))
## The number rows in the tables to be merged do not match, which may result in
## rows appearing out of order.
## ℹ See `tbl_merge()` (`?gtsummary::tbl_merge()`) help file for details. Use
##   `quiet=TRUE` to silence message.
CharacteristicDrug A
N = 98
Drug B
N = 102
Parametric p-value1Non-Parametric p-value2Adjusted GLM p-value3
Age

0.8340.7180.650
    N Non-missing9198


    Median (Q1, Q3)46.00 (37.00, 60)48.00 (39.00, 56)


    Min, Max6.00, 78.009.00, 83.00


    (Missing)7/98 (7.1)%)4/102 (3.9)%)


Grade

0.8710.9060.872
    I35 (35.7%)33 (32.4%)


    II32 (32.7%)36 (35.3%)


    III31 (31.6%)33 (32.4%)


    Unknown




1 Welch Two Sample t-test; Pearson’s Chi-squared test
2 Wilcoxon rank sum test; Fisher’s exact test
3 Logistic regression adjusted for marker level
Abbreviations: Adding abbreviation on top of the existing ones, the function will reorder them by alphabetical, CI = Confidence Interval

Including Columns for All Post-Hoc Pairwise Comparisons

  • This is from Daniel the package author.
# set theme to get MEAN (SD) by default in `tbl_summary()`
# theme_gtsummary_mean_sd()

# function to add pairwise copmarisons to `tbl_summary()`
add_stat_pairwise <- function(data, variable, by, ...) {
  # calculate pairwise p-values
  pw <- pairwise.t.test(data[[variable]], data[[by]], p.adj = "BH") # # c("holm", "hochberg", "hommel", "bonferroni", "BH", "BY", "fdr", "none")

  # convert p-values to list
  index <- 0L
  p.value.list <- list()
  for (i in seq_len(nrow(pw$p.value))) {
    for (j in seq_len(nrow(pw$p.value))) {
      index <- index + 1L
      
      p.value.list[[index]] <- 
        c(pw$p.value[i, j]) %>%
        setNames(glue::glue("**{colnames(pw$p.value)[j]} vs. {rownames(pw$p.value)[i]}**"))
    }
  }
  
  # convert list to data frame
  p.value.list %>% 
    unlist() %>%
    purrr::discard(is.na) %>%
    t() %>%
    as.data.frame() %>%
    # formatting/roundign p-values
    dplyr::mutate(dplyr::across(everything(), style_pvalue))
}

data %>%
  select(grade, age, marker) %>%
  tbl_summary(by = grade, missing = "no", digits = ~1) %>%
  # add pariwaise p-values
  add_stat(everything() ~ add_stat_pairwise)
CharacteristicI
N = 68
1
II
N = 68
1
III
N = 64
1
I vs. III vs. IIIII vs. III
Age47.0 (37.0, 56.0)48.5 (37.0, 57.0)47.0 (38.0, 58.0)0.80.80.8
Marker Level (ng/mL)1.0 (0.3, 1.6)0.4 (0.1, 1.1)0.6 (0.3, 1.7)0.0310.60.059
1 Median (Q1, Q3)

Univariable Regression

univariable_tbl = tbl_uvregression(
    data %>% dplyr::select(ttdeath, death, trt, age, stage, grade),
    method = coxph,
    y = Surv(ttdeath, death),
    exponentiate = TRUE,
    label = list(trt = "Treatment", age = "Age", 
                 stage = "Stage", grade = "Grade"), # Can also label in the tbl_function
    pvalue_fun = function(x) style_pvalue(x, digits = 3)
    ) %>% 
  add_global_p(include=c("stage", "grade"), keep=FALSE) %>% 
  modify_column_merge( # Combine the estimate and CI columns
    pattern = "{estimate} [{conf.low}, {conf.high}]",
    rows = !reference_row %in% TRUE & !is.na(conf.low)
  ) %>% 
  modify_table_styling( # Add REF to reference level
    columns = c(estimate),
    rows = reference_row %in% TRUE,
    missing_symbol = "REF"
  ) %>% 
  modify_header(estimate = "**HR [95% CI]**") %>% 
  modify_column_hide("stat_n") # remove the N column
  # modify_caption(caption = "PFS of Cohort A+C") %>% 
  # modify_spanning_header(c(stat_n, estimate, conf.low, p.value) ~ "**Univariable**")

Multivariable Regression

multivariable_tbl <- coxph(
   Surv(ttdeath, death) ~ age + stage, data = data
   ) %>%
  tbl_regression(
      exponentiate=TRUE,
      label = list(age = "Age",
                   stage = "Stage")
   ) %>% 
  add_global_p(include=c("stage"), keep=FALSE) %>% 
  modify_column_merge(
    pattern = "{estimate} [{conf.low}, {conf.high}]",
    rows = !reference_row %in% TRUE & !is.na(conf.low)
  ) %>% 
  modify_table_styling(
    columns = c(estimate),
    rows = reference_row %in% TRUE,
    missing_symbol = "REF"
  ) %>% 
  modify_header(estimate = "**HR [95% CI]**")

Merge and group column by each table

tbl_merge(tbls = list(univariable_tbl, multivariable_tbl),
                     tab_spanner = c("**Univariable Models**", "**Multivariable Model**"))
## The number rows in the tables to be merged do not match, which may result in
## rows appearing out of order.
## ℹ See `tbl_merge()` (`?gtsummary::tbl_merge()`) help file for details. Use
##   `quiet=TRUE` to silence message.
Characteristic
Univariable Models
Multivariable Model
HR [95% CI]p-valueHR [95% CI]p-value
Treatment



    Drug AREF


    Drug B1.25 [0.86, 1.81]


Age1.01 [0.99, 1.02]0.3321.01 [1.00, 1.02]0.14
Stage
0.002
0.002
    T1REF
REF
    T21.18 [0.68, 2.04]
1.31 [0.74, 2.29]
    T31.23 [0.69, 2.20]
1.20 [0.65, 2.19]
    T42.48 [1.49, 4.14]
2.67 [1.55, 4.59]
Grade
0.075

    IREF


    II1.28 [0.80, 2.05]


    III1.69 [1.07, 2.66]


Abbreviations: CI = Confidence Interval, HR = Hazard Ratio

Merge and group title by header names

  • Code only, no example data or output.
## To check the header names
# show_header_names(pfs_os_tbl)


pfs_os_tbl = tbl_merge(tbls = list(pfs_table, os_table),
                     tab_spanner = FALSE) %>% 
  modify_caption(caption = "PFS and OS table.") %>% 
  modify_abbreviation(c("PFS = Progression Free Survival, OS = Overall Survival")) %>% 
  as_gt() %>% 
  # gt::cols_width(
  #   label	~ px(180),
  #   everything() ~ px(80)
  # ) %>% 
  gt::tab_spanner(
    columns = c(estimate_1_1, conf.low_1_1, p.value_1_1, estimate_2_1, conf.low_2_1, p.value_2_1),
    label = "PFS",
    gather = FALSE
  ) %>% 
  gt::tab_spanner(
    columns = c(estimate_1_2, conf.low_1_2, p.value_1_2, estimate_2_2, conf.low_2_2, p.value_2_2),
    label = "OS",
    gather = FALSE
  ) %>% 
  gt::tab_style(
    style = gt::cell_text(weight = "bold"),
    locations = list(
      gt::cells_column_spanners(matches(c("PFS","OS")))
      )
    ) %>% 
  gt::tab_options(latex.tbl.pos = "ht")

Convert gtsummary Object to Other Objects

as_gt()
as_kable_extra()
as_flex_table()

Code for Testing

t.test(data$age ~ data$trt)
wilcox.test(data$age ~ data$trt)

chisq.test(table(data$grade, data$trt))
fisher.test(table(data$grade, data$trt))

summary(glm(trt ~ age + marker, data = data, family = "binomial"))

fit = glm(trt ~ grade + marker, data = data, family = "binomial")
car::Anova(fit)

summary(coxph(Surv(ttdeath, death) ~ stage, data = data))

Session Info

sessionInfo()
## R version 4.6.0 (2026-04-24 ucrt)
## Platform: x86_64-w64-mingw32/x64
## Running under: Windows 11 x64 (build 26200)
## 
## Matrix products: default
##   LAPACK version 3.12.1
## 
## locale:
## [1] LC_COLLATE=English_United States.utf8 
## [2] LC_CTYPE=English_United States.utf8   
## [3] LC_MONETARY=English_United States.utf8
## [4] LC_NUMERIC=C                          
## [5] LC_TIME=English_United States.utf8    
## 
## time zone: America/New_York
## tzcode source: internal
## 
## attached base packages:
## [1] stats     graphics  grDevices utils     datasets  methods   base     
## 
## other attached packages:
##  [1] survival_3.8-6  gtsummary_2.5.1 lubridate_1.9.5 forcats_1.0.1  
##  [5] stringr_1.6.0   dplyr_1.2.1     purrr_1.2.2     readr_2.2.0    
##  [9] tidyr_1.3.2     tibble_3.3.1    ggplot2_4.0.3   tidyverse_2.0.0
## 
## loaded via a namespace (and not attached):
##  [1] gtable_0.3.6         bayestestR_0.18.1    xfun_0.60           
##  [4] bslib_0.11.0         insight_1.5.2        lattice_0.22-9      
##  [7] tzdb_0.5.0           vctrs_0.7.3          tools_4.6.0         
## [10] generics_0.1.4       datawizard_1.3.1     pkgconfig_2.0.3     
## [13] Matrix_1.7-5         RColorBrewer_1.1-3   S7_0.2.2            
## [16] gt_1.3.0             lifecycle_1.0.5      compiler_4.6.0      
## [19] farver_2.1.2         textshaping_1.0.5    carData_3.0-6       
## [22] litedown_0.10        htmltools_0.5.9      sass_0.4.10         
## [25] yaml_2.3.12          Formula_1.2-5        pillar_1.11.1       
## [28] car_3.1-5            jquerylib_0.1.4      broom.helpers_1.22.0
## [31] cachem_1.1.0         abind_1.4-8          commonmark_2.0.0    
## [34] tidyselect_1.2.1     digest_0.6.39        stringi_1.8.7       
## [37] bookdown_0.47        splines_4.6.0        labelled_2.16.0     
## [40] fastmap_1.2.0        grid_4.6.0           cli_3.6.6           
## [43] magrittr_2.0.5       cards_0.8.1          broom_1.0.13        
## [46] withr_3.0.3          scales_1.4.0         backports_1.5.1     
## [49] cardx_0.3.4          timechange_0.4.0     rmarkdown_2.31      
## [52] otel_0.2.0           blogdown_1.24        hms_1.1.4           
## [55] kableExtra_1.4.1     evaluate_1.0.5       knitr_1.51          
## [58] haven_2.5.5          parameters_0.29.2    viridisLite_0.4.3   
## [61] markdown_2.0         rlang_1.3.0          glue_1.8.1          
## [64] xml2_1.6.0           svglite_2.2.2        rstudioapi_0.19.0   
## [67] jsonlite_2.0.0       R6_2.6.1             fs_2.1.0            
## [70] systemfonts_1.3.2