gtsummary for beginners: build a Table 1 from clinical data, step by step

clinical
biostatistics
gtsummary
r-tutorial
A gentle, hands-on gtsummary tutorial in R. Starting from a clinical trial dataset, we build a publication-ready Table 1 one verb at a time — then you can run it yourself in the browser.
Author

Rverse Analytics

Published

July 10, 2026

If you’ve ever built a “Table 1” — the baseline-characteristics table at the start of every clinical paper — by hand, you know it’s slow and easy to get wrong. The gtsummary package builds it correctly from your data with a few readable lines. This tutorial goes slowly, one step at a time, so you can see exactly what each piece does. Prefer to experiment as you read? Skip to the live demo and run gtsummary in your browser — no install.

Note

Already comfortable with the basics and just want the recipe? Our one-line Table 1 reference is the quick version. This post is the slow, first-time walkthrough.

The data

We’ll use trial, a small clinical-trial dataset that ships with gtsummary — 200 patients, with treatment, age, tumour marker, stage and grade. Because it comes with the package, you can follow along with no downloads.

library(gtsummary)

trial |>
  dplyr::select(trt, age, marker, stage, grade) |>
  head()
# A tibble: 6 × 5
  trt      age marker stage grade
  <chr>  <dbl>  <dbl> <fct> <fct>
1 Drug A    23  0.16  T1    II   
2 Drug B     9  1.11  T2    I    
3 Drug A    31  0.277 T1    II   
4 Drug A    NA  2.07  T3    III  
5 Drug A    51  2.77  T4    III  
6 Drug B    39  0.613 T4    I    

Each column already carries a human-readable label (for example age is labelled “Age”) — gtsummary uses these automatically, which is part of why its tables look finished.

Step 1 — the simplest possible summary

Pass the data to tbl_summary(). That’s it:

trial |>
  dplyr::select(age, marker, stage, grade) |>
  tbl_summary()
Characteristic N = 2001
Age 47 (38, 57)
    Unknown 11
Marker Level (ng/mL) 0.64 (0.22, 1.41)
    Unknown 10
T Stage
    T1 53 (27%)
    T2 54 (27%)
    T3 43 (22%)
    T4 50 (25%)
Grade
    I 68 (34%)
    II 68 (34%)
    III 64 (32%)
1 Median (Q1, Q3); n (%)

Notice what happened without any instructions from us: continuous variables (age, marker) are summarised as median (IQR), categorical ones (stage, grade) as n (%), and missing values are counted. gtsummary chose a sensible default for every variable type.

Step 2 — split by treatment group

A Table 1 almost always compares groups. Add by = trt to get one column per treatment arm:

trial |>
  dplyr::select(trt, age, marker, stage, grade) |>
  tbl_summary(by = trt)
Characteristic Drug A
N = 981
Drug B
N = 1021
Age 46 (37, 60) 48 (39, 56)
    Unknown 7 4
Marker Level (ng/mL) 0.84 (0.23, 1.60) 0.52 (0.18, 1.21)
    Unknown 6 4
T Stage

    T1 28 (29%) 25 (25%)
    T2 25 (26%) 29 (28%)
    T3 22 (22%) 21 (21%)
    T4 23 (23%) 27 (26%)
Grade

    I 35 (36%) 33 (32%)
    II 32 (33%) 36 (35%)
    III 31 (32%) 33 (32%)
1 Median (Q1, Q3); n (%)

Now each characteristic is shown separately for the two treatment groups — the shape every reader expects from a Table 1.

Step 3 — add a p-value

Reviewers usually want a statistical test comparing the groups. add_p() picks an appropriate test for each row automatically — Wilcoxon for continuous, chi-squared or Fisher for categorical:

trial |>
  dplyr::select(trt, age, marker, stage, grade) |>
  tbl_summary(by = trt) |>
  add_p()
Characteristic Drug A
N = 981
Drug B
N = 1021
p-value2
Age 46 (37, 60) 48 (39, 56) 0.7
    Unknown 7 4
Marker Level (ng/mL) 0.84 (0.23, 1.60) 0.52 (0.18, 1.21) 0.085
    Unknown 6 4
T Stage

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

0.9
    I 35 (36%) 33 (32%)
    II 32 (33%) 36 (35%)
    III 31 (32%) 33 (32%)
1 Median (Q1, Q3); n (%)
2 Wilcoxon rank sum test; Pearson’s Chi-squared test

You didn’t choose the tests — gtsummary matched each one to the variable type. That removes a common source of error (running the wrong test on the wrong kind of variable).

Step 4 — an overall column

To also show the whole sample alongside the groups, add add_overall():

trial |>
  dplyr::select(trt, age, marker, stage, grade) |>
  tbl_summary(by = trt) |>
  add_p() |>
  add_overall()
Characteristic Overall
N = 2001
Drug A
N = 981
Drug B
N = 1021
p-value2
Age 47 (38, 57) 46 (37, 60) 48 (39, 56) 0.7
    Unknown 11 7 4
Marker Level (ng/mL) 0.64 (0.22, 1.41) 0.84 (0.23, 1.60) 0.52 (0.18, 1.21) 0.085
    Unknown 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 Median (Q1, Q3); n (%)
2 Wilcoxon rank sum test; Pearson’s Chi-squared test

Step 5 — polish for publication

A few finishing touches make it manuscript-ready: relabel variables, switch continuous summaries to mean (SD), show missing counts explicitly, bold the row labels, and add a caption.

trial |>
  dplyr::select(trt, age, marker, stage, grade) |>
  tbl_summary(
    by = trt,
    label = list(age ~ "Age (years)", marker ~ "Marker (ng/mL)"),
    statistic = list(all_continuous() ~ "{mean} ({sd})"),
    missing_text = "Missing"
  ) |>
  add_p() |>
  add_overall() |>
  bold_labels() |>
  modify_caption("**Table 1. Baseline characteristics by treatment group**")
Table 1. Baseline characteristics by treatment group
Characteristic Overall
N = 2001
Drug A
N = 981
Drug B
N = 1021
p-value2
Age (years) 47 (14) 47 (15) 47 (14) 0.7
    Missing 11 7 4
Marker (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

Read the pipeline top to bottom and it says exactly what it does: summarise by treatment, add a p-value, add an overall column, bold the labels, add a caption. That readability is the point — anyone can check the table against the code.

Why build it this way

The real win isn’t saving a few minutes once. It’s that the table is code. Fix a data point, rerun, and every number — including the tests and percentages — updates together. There’s never a moment where the manuscript and the data disagree.

Try it yourself

Two ways to run gtsummary right now, in your browser, on this same clinical dataset — no R install:

  • Point-and-click Table 1 explorer — choose the grouping variable and which characteristics to include, and watch the table rebuild. A real Shiny app running on WebAssembly.
  • Editable code demo — edit the gtsummary code and run it live, so you can try each verb from this tutorial yourself.

Next steps once you’re comfortable: gtsummary also builds regression tables in one line — see our posts on logistic regression odds ratios and Cox proportional hazards.


More clinical recipes live in our Clinical R toolkit. Need publication-ready tables on your own data? Get in touch.