Making a Manuscript Table 1 in R: tableone, table1, and gtsummary Compared

Almost every clinical or epidemiological paper opens with the same table: Table 1, the table of baseline characteristics that describes who was in the study, usually split by treatment arm or exposure group. It is tedious to build by hand — count the categories, compute means and standard deviations, decide which variables are skewed enough to need a median, format everything to the same number of decimals, and keep it all aligned when a reviewer asks you to add one more row. R has several packages that do the whole thing from a single line. This post builds the same Table 1 three ways — with tableone, table1, and gtsummary — on real trial data, and ends with a recommendation on which to reach for.

The data

We use the pbc dataset from the survival package (it ships with base R, no download). It is the Mayo Clinic trial of D-penicillamine versus placebo in primary biliary cirrhosis. We keep the 312 randomized patients, which is exactly the population a real Table 1 would describe, and pick a handful of baseline variables: age, sex, disease stage, ascites, serum bilirubin, albumin, and platelet count.

library(survival)
library(dplyr)
library(tidyr)

pbc_t1 <- pbc %>%
  filter(!is.na(trt)) %>% # the 312 randomized patients
  mutate(
    trt = factor(trt, 1:2, c("D-penicillamine", "Placebo")),
    sex = factor(sex, c("f", "m"), c("Female", "Male")),
    stage = factor(stage),
    ascites = factor(ascites, 0:1, c("No", "Yes"))
  ) %>%
  select(trt, age, sex, stage, ascites, bili, albumin, platelet)

Always look at the data before summarizing it. Each row is one patient, trt is the two-arm grouping variable, and the rest are the baseline characteristics we want in the table.

head(pbc_t1)
##               trt      age    sex stage ascites bili albumin platelet
## 1 D-penicillamine 58.76523 Female     4     Yes 14.5    2.60      190
## 2 D-penicillamine 56.44627 Female     3      No  1.1    4.14      221
## 3 D-penicillamine 70.07255   Male     4      No  1.4    3.48      151
## 4 D-penicillamine 54.74059 Female     4      No  1.8    2.54      183
## 5         Placebo 38.10541 Female     3      No  3.4    3.53      136
## 6         Placebo 66.25873 Female     3      No  0.8    3.98       NA

Two features of this data are exactly what makes Table 1 fiddly, so they are worth checking before we start: is any continuous variable skewed, and is anything missing? You do not need to know the answer in advance — compare the mean and median of every numeric variable, and skew shows itself wherever the two pull apart.

# mean vs median for every numeric variable
pbc_t1 %>%
  summarise(across(
    where(is.numeric),
    list(mean = mean, median = median),
    na.rm = TRUE
  )) %>%
  pivot_longer(
    everything(),
    names_to = c("variable", ".value"),
    names_sep = "_"
  )
## # A tibble: 4 × 3
##   variable   mean median
##   <chr>     <dbl>  <dbl>
## 1 age       50.0   49.8 
## 2 bili       3.26   1.35
## 3 albumin    3.52   3.55
## 4 platelet 262.   257
# missing values per variable
pbc_t1 %>% summarise(across(everything(), \(x) sum(is.na(x))))
##   trt age sex stage ascites bili albumin platelet
## 1   0   0   0     0       0    0       0        4

For age, albumin, and platelets the mean and median nearly coincide, so a mean (SD) describes them fairly. Serum bilirubin is the exception — its mean (3.26) is more than double its median (1.35), the signature of a strong right skew, so it should be summarized as a median with interquartile range. And platelet count has 4 missing values, which a good Table 1 reports rather than silently drops. Watch how each package handles both.

Package 1: tableone

tableone is the workhorse of the three. You hand CreateTableOne() a vector of variable names, the stratifying variable, and which variables are categorical, and it returns an object you print().

library(tableone)

vars <- c("age", "sex", "stage", "ascites", "bili", "albumin", "platelet")

tab <- CreateTableOne(
  vars = vars,
  strata = "trt",
  data = pbc_t1,
  factorVars = c("sex", "stage", "ascites")
)

print(tab, nonnormal = "bili", smd = TRUE)
##                       Stratified by trt
##                        D-penicillamine     Placebo             p      test    SMD   
##   n                       158                 154                                   
##   age (mean (SD))       51.42 (11.01)       48.58 (9.96)        0.018          0.270
##   sex = Male (%)           21 (13.3)           15 ( 9.7)        0.421          0.111
##   stage (%)                                                     0.201          0.246
##      1                     12 ( 7.6)            4 ( 2.6)                            
##      2                     35 (22.2)           32 (20.8)                            
##      3                     56 (35.4)           64 (41.6)                            
##      4                     55 (34.8)           54 (35.1)                            
##   ascites = Yes (%)        14 ( 8.9)           10 ( 6.5)        0.567          0.089
##   bili (median [IQR])    1.40 [0.80, 3.20]   1.30 [0.72, 3.60]  0.842 nonnorm  0.171
##   albumin (mean (SD))    3.52 (0.44)         3.52 (0.40)        0.874          0.018
##   platelet (mean (SD)) 258.75 (100.32)     265.20 (90.73)       0.555          0.067

The nonnormal = "bili" argument switches bilirubin to median [IQR] while leaving the other continuous variables as mean (SD), and smd = TRUE adds the standardized mean difference — the between-group difference in standard-deviation units, which is increasingly what journals want in place of p-values for a descriptive table (more on that below).

That console output is perfect for a quick look, but it is monospaced text — awkward to move into a manuscript. The fix is to capture the printed object as a character matrix (printToggle = FALSE) and hand it to kableExtra, which renders a proper HTML table you can copy straight into Word or share as a web page:

library(kableExtra)

mat <- print(
  tab,
  nonnormal = "bili",
  smd = TRUE,
  printToggle = FALSE,
  noSpaces = TRUE
) # returns a character matrix

caption <- paste0(
  '<span style="display:block;padding:.6em;font-weight:600;">',
  "Table 1. Baseline characteristics, by treatment arm</span>"
)

mat[, colnames(mat) != "test"] %>% # drop tableone's internal flag column
  kbl(align = "r", caption = caption, escape = FALSE) %>%
  kable_styling(full_width = TRUE)
Table 1. Baseline characteristics, by treatment arm
D-penicillamine Placebo p SMD
n 158 154
age (mean (SD)) 51.42 (11.01) 48.58 (9.96) 0.018 0.270
sex = Male (%) 21 (13.3) 15 (9.7) 0.421 0.111
stage (%) 0.201 0.246
1 12 (7.6) 4 (2.6)
2 35 (22.2) 32 (20.8)
3 56 (35.4) 64 (41.6)
4 55 (34.8) 54 (35.1)
ascites = Yes (%) 14 (8.9) 10 (6.5) 0.567 0.089
bili (median [IQR]) 1.40 [0.80, 3.20] 1.30 [0.72, 3.60] 0.842 0.171
albumin (mean (SD)) 3.52 (0.44) 3.52 (0.40) 0.874 0.018
platelet (mean (SD)) 258.75 (100.32) 265.20 (90.73) 0.555 0.067

printToggle = FALSE suppresses the console dump and returns the table as a matrix instead; noSpaces = TRUE strips the alignment padding tableone adds for the text view. From there kbl() plus kable_styling() gives a clean, copy-pasteable table — the same trick works for write.csv(mat, "table1.csv") if a co-author wants the raw numbers.

Package 2: table1

table1 takes a formula interface — variables on the left of the |, the grouping variable on the right — and renders directly to a clean HTML table. It also lets you attach human-readable labels and units to columns, which show up verbatim in the output.

library(table1)

label(pbc_t1$age) <- "Age"
units(pbc_t1$age) <- "years"
label(pbc_t1$bili) <- "Serum bilirubin"
label(pbc_t1$albumin) <- "Albumin"
label(pbc_t1$platelet) <- "Platelet count"
label(pbc_t1$stage) <- "Histologic stage"

table1(
  ~ age + sex + stage + ascites + bili + albumin + platelet | trt,
  data = pbc_t1
)

D-penicillamine
(N=158)
Placebo
(N=154)
Overall
(N=312)
Age (years)
Mean (SD) 51.4 (11.0) 48.6 (9.96) 50.0 (10.6)
Median [Min, Max] 51.9 [26.3, 78.4] 48.1 [30.6, 74.5] 49.8 [26.3, 78.4]
sex
Female 137 (86.7%) 139 (90.3%) 276 (88.5%)
Male 21 (13.3%) 15 (9.7%) 36 (11.5%)
Histologic stage
1 12 (7.6%) 4 (2.6%) 16 (5.1%)
2 35 (22.2%) 32 (20.8%) 67 (21.5%)
3 56 (35.4%) 64 (41.6%) 120 (38.5%)
4 55 (34.8%) 54 (35.1%) 109 (34.9%)
ascites
No 144 (91.1%) 144 (93.5%) 288 (92.3%)
Yes 14 (8.9%) 10 (6.5%) 24 (7.7%)
Serum bilirubin
Mean (SD) 2.87 (3.63) 3.65 (5.28) 3.26 (4.53)
Median [Min, Max] 1.40 [0.300, 20.0] 1.30 [0.300, 28.0] 1.35 [0.300, 28.0]
Albumin
Mean (SD) 3.52 (0.443) 3.52 (0.396) 3.52 (0.420)
Median [Min, Max] 3.57 [2.10, 4.64] 3.55 [1.96, 4.38] 3.55 [1.96, 4.64]
Platelet count
Mean (SD) 259 (100) 265 (90.7) 262 (95.6)
Median [Min, Max] 255 [62.0, 563] 260 [71.0, 487] 257 [62.0, 563]
Missing 2 (1.3%) 2 (1.3%) 4 (1.3%)

Notice what table1 does not print: there is no p-value column by default. That is a deliberate design choice, and a defensible one — many journals and the CONSORT reporting guidance discourage significance testing of baseline characteristics in a randomized trial, because any imbalance is by definition due to chance. table1 also adds an "Overall" column automatically and reports every continuous variable as both mean (SD) and median [IQR] out of the box, so you rarely have to tell it which variables are skewed. It is the quickest way to a presentable HTML table, but customizing the statistics (or adding a p-value) requires writing a small render function.

Package 3: gtsummary

gtsummary has become the de facto modern standard. tbl_summary() inspects each column, guesses whether it is continuous or categorical, picks sensible statistics and decimal places, and reports missingness as an "Unknown" row — all without configuration. It then composes with helper functions like add_p() and add_overall(), and exports to Word, HTML, PDF, or LaTeX through the gt and flextable back ends.

library(gtsummary)

caption <- paste0(
  '<span style="display:block;padding:.6em;font-weight:600;">',
  "Table 1. Baseline characteristics, by treatment arm</span>"
)

pbc_t1 %>%
  tbl_summary(
    by = trt,
    statistic = list(all_continuous() ~ "{median} ({p25}, {p75})"),
    label = list(age ~ "Age (years)", bili ~ "Serum bilirubin")
  ) %>%
  add_p() %>%
  add_overall() %>%
  as_kable_extra(caption = caption, escape = FALSE) %>% # portable HTML table
  kable_styling(full_width = TRUE)
Table 1. Baseline characteristics, by treatment arm
Characteristic Overall
N = 312
D-penicillamine
N = 158
Placebo
N = 154
p-value
Age (years) 50 (42, 57) 52 (43, 59) 48 (41, 56) 0.020
sex 0.3
Female 276 (88%) 137 (87%) 139 (90%)
Male 36 (12%) 21 (13%) 15 (9.7%)
Histologic stage 0.2
1 16 (5.1%) 12 (7.6%) 4 (2.6%)
2 67 (21%) 35 (22%) 32 (21%)
3 120 (38%) 56 (35%) 64 (42%)
4 109 (35%) 55 (35%) 54 (35%)
ascites 24 (7.7%) 14 (8.9%) 10 (6.5%) 0.4
Serum bilirubin 1.4 (0.8, 3.5) 1.4 (0.8, 3.2) 1.3 (0.7, 3.6) 0.8
Albumin 3.55 (3.31, 3.80) 3.57 (3.21, 3.83) 3.55 (3.34, 3.78) >0.9
Platelet count 257 (200, 323) 255 (189, 323) 260 (207, 323) 0.5
Unknown 4 2 2
1 Median (Q1, Q3); n (%)
2 Wilcoxon rank sum test; Pearson’s Chi-squared test

Here we asked for medians with quartiles for every continuous variable via all_continuous(); drop that line and gtsummary picks median [IQR] anyway, because it defaults to non-parametric summaries. add_p() chooses the test for you — Wilcoxon for continuous, chi-squared or Fisher for categorical — and add_overall() prepends the combined column.

One publishing wrinkle worth knowing: by default gtsummary renders through the gt package, which wraps the table in a large embedded <style> block. That looks perfect in RStudio or a knitted HTML file, but many content systems (WordPress, Ghost, email clients) strip <style> tags and leave the CSS dumped as visible text. Piping to as_kable_extra() sidesteps it — you get the same table with the styling applied inline on each cell, which survives copy-paste into a CMS or Word. The single biggest reason gtsummary wins for manuscripts, though, is the export path: as_gt(), as_flex_table(), or as_kable_extra() each give you a publication-formatted object you can drop straight into an R Markdown or Quarto document, or save as a .docx for a co-author who lives in Word.

If a journal wants more decimals, the digits argument controls precision — one entry for the continuous statistics and one for the counts and percentages. Add it to the tbl_summary() call above:

tbl_summary(
  by = trt,
  digits = list(
    all_continuous() ~ 2, # 2 decimals on means / medians
    all_categorical() ~ c(0, 1)
  ) # counts whole, percentages to 1 decimal
)

Which one is best?

All three produce a correct Table 1 from one function call, so the choice is about the last mile — getting the table into a manuscript and bending its formatting to a journal’s house style.

tableone table1 gtsummary
Interface variable vector formula pipe / formula
Default output console text HTML HTML / gt
p-values opt-in write your own add_p()
Standardized mean difference smd = TRUE no add_difference()
Word / PDF / LaTeX export via CSV HTML mainly first-class
Best for quick check, cohort balance fast clean HTML manuscripts

For a manuscript, reach for gtsummary. It has the sanest defaults, the richest set of add-ons, and — decisively — a real export pipeline to Word and LaTeX, which is where Table 1 actually has to end up. For a quick look while you are still exploring, or for the balance table in a propensity-score or matched-cohort analysis where the standardized mean difference is the quantity of interest, tableone is faster to type and puts the SMD one argument away. table1 sits between them: if you want a clean HTML table with almost no configuration and no p-values, it is the most direct route.

A note on p-values in Table 1

The CONSORT guidelines advise against reporting p-values in a trial’s Table 1. Prefer the standardized mean difference (smd = TRUE in tableone, add_difference() in gtsummary) to flag imbalances worth adjusting for — it does not depend on sample size the way a p-value does, so it answers the question you actually care about: is this difference large enough to matter? Keep p-values for observational Table 1s, where a genuine group comparison is the point.

Leave a comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.