Skip to contents

Answers to the questions that come up most, each with a table showing the result. This article follows gtsummary’s article of the same name, so the questions are gtsummary’s.


Frequently asked questions

Headers, labels and formatting

  1. How do I modify column headers in a table?

  2. How do I add a spanning header to a table?

  3. How do I change variable labels?

  4. How do I italicize or bold labels in a table?

  5. How do I italicize or bold levels in a table?

  6. How do I show a statistic label next to each variable?

  7. How do I present the table with a decimal comma?

Adding and modifying statistics

  1. How do I add the number of observations to a summary table?

  2. How do I show additional summary statistics as a new row?

  3. How do I report the mean and standard deviation instead of the median?

  4. How do I report a statistic that ltsummary does not compute?

  5. How do I change the number of decimal places?

  6. How do I show row percentages, or only percentages?

  7. How do I include a column for missing values of a grouping variable?

  8. How do I add a column with the confidence interval around the mean?

  9. How do I add a column for the difference between groups?

  10. How do I compare each group with a single reference group?

  11. How do I change the p-value format?

  12. How do I summarize a numeric variable with few distinct values as continuous?

  13. How do I show a chosen level of a categorical variable on one row?

Statistical tests

  1. How do I use a t-test, or Fisher’s exact test, instead of the defaults?

  2. How do I do a paired t-test or McNemar’s test?

  3. How do I run a test that is not built in?

Cross and survival tables

  1. How do I cross-tabulate two variables with a p-value?

  2. How do I report survival probabilities or the median survival time?

Combining tables

  1. How do I stratify a summary table by a second variable?

  2. How do I summarize a continuous variable by two categorical variables?


Summary tables

Add a spanning header over the group columns for clarity, and modify the column headers to show the group size. bold_labels() formats the labels in bold; italicize_labels() is the italic counterpart, and the two combine.

trial |>
  tbl_summary(
    by = trt,
    include = c(age, grade),
    missing = "no",
    statistic = all_continuous() ~ "{median} ({p25}, {p75})"
  ) |>
  modify_header(all_stat_cols() ~ "**{level}**  \nN = {n} ({style_percent(p)}%)") |>
  add_n() |>
  bold_labels() |>
  modify_spanning_header(all_stat_cols() ~ "**Chemotherapy Treatment**")

Show continuous summary statistics on multiple lines. The levels are italicized with italicize_levels(); bold_levels() makes them bold, and both can be used together.

trial |>
  tbl_summary(
    by = trt,
    include = c(age, marker),
    type = all_continuous() ~ "continuous2",
    statistic =
      all_continuous() ~ c("{N_nonmiss}",
                           "{mean} ({sd})",
                           "{median} ({p25}, {p75})",
                           "{min}, {max}"),
    missing = "no"
  ) |>
  italicize_levels()

Change the function that formats the p-values and update the variable labels. Here the table is split by tumor response, so the response variable is recoded with descriptive levels first.

trial$response <- factor(trial$response, labels = c("No Tumor Response", "Tumor Responded"))

trial |>
  tbl_summary(
    by = response,
    include = c(age, grade),
    missing = "no",
    label = list(age ~ "Patient Age", grade ~ "Tumor Grade")
  ) |>
  add_p(pvalue_fun = label_style_pvalue(digits = 2))

Include the patients with a missing tumor response as a column of their own. tbl_summary() drops rows with a missing by value, so the missing values become a level first.

trial$response <- addNA(trial$response)
levels(trial$response)[is.na(levels(trial$response))] <- "Missing Response Status"

trial |>
  tbl_summary(
    by = response,
    include = c(age, grade),
    label = list(age ~ "Patient Age", grade ~ "Tumor Grade")
  )

Show the statistic next to each variable label instead of in a footnote, or in a column of its own.

trial |>
  tbl_summary(by = trt, include = c(age, grade), missing = "no") |>
  add_stat_label(label = all_continuous() ~ "Median (IQR)")

trial |>
  tbl_summary(by = trt, include = c(age, grade), missing = "no") |>
  add_stat_label(location = "column")

Report the mean and standard deviation for the continuous variables, and counts with percentages in the form n / N.

trial |>
  tbl_summary(
    by = trt,
    include = c(age, marker, grade),
    statistic = list(
      all_continuous() ~ "{mean} ({sd})",
      all_categorical() ~ "{n} / {N} ({p}%)"
    ),
    missing = "no"
  )

Any function of one argument can be a statistic: name it in braces. The function is called on the non-missing values of the variable. Here the geometric mean of the marker level and the interquartile range of age are reported, and add_stat_label() names them.

geo_mean <- function(x) exp(mean(log(x)))

trial |>
  tbl_summary(
    by = trt,
    include = c(age, marker),
    statistic = list(age ~ "{median} ({IQR})", marker ~ "{geo_mean}"),
    missing = "no"
  ) |>
  add_stat_label(label = list(age ~ "Median (IQR)", marker ~ "Geometric mean"))

Set the number of decimal places per variable, per statistic, or with a formatting function. A vector is matched to the statistics in order; a named list changes one statistic and leaves the others at their defaults.

trial |>
  tbl_summary(
    by = trt,
    include = c(age, marker, grade),
    statistic = all_continuous() ~ "{mean} ({sd})",
    digits = list(
      age ~ c(1, 2),
      marker ~ label_style_sigfig(digits = 3),
      grade ~ list(p = 1)
    ),
    missing = "no"
  )

Report row percentages, and show only the percentage for a dichotomous variable.

trial |>
  tbl_summary(
    by = trt,
    include = c(grade, response),
    percent = "row",
    statistic = list(grade ~ "{n} ({p}%)", response ~ "{p}%"),
    digits = response ~ 1,
    missing = "no"
  ) |>
  modify_header(all_stat_cols() ~ "**{level}**") |>
  modify_footnote_header("Row percentages", columns = all_stat_cols())

A numeric variable with fewer than ten distinct values is summarized as categorical by default. Set its type to summarize it as continuous; the reverse works as well.

trial$grade_num <- as.integer(trial$grade)

trial |>
  tbl_summary(
    include = c(grade_num, age),
    type = list(grade_num ~ "continuous"),
    label = list(grade_num ~ "Grade (numeric)")
  )

trial <- ltsummary::trial

Show one level of a categorical variable on a single row with the value argument, which turns the variable into a dichotomous one.

trial |>
  tbl_summary(
    by = trt,
    include = c(grade, stage),
    value = list(grade ~ "III", stage ~ "T4"),
    label = list(grade ~ "Grade III", stage ~ "Stage T4")
  )

Use a decimal comma throughout. When the decimal mark is a comma, the thousands separator defaults to a thin space, as in gtsummary.

options(ltsummary.decimal.mark = ",")

trial |>
  tbl_summary(
    by = trt,
    include = c(age, marker),
    statistic = all_continuous() ~ "{mean} ({sd})",
    missing = "no"
  ) |>
  add_p()

options(ltsummary.decimal.mark = NULL)

Statistical tests

Choose the tests by variable or by summary type. Arguments are passed to a test with test.args; all_tests() selects every variable compared with a given test. The tests reference lists the built-in tests with the code each one runs.

trial |>
  tbl_summary(by = trt, include = c(age, marker, grade), missing = "no") |>
  add_p(
    test = list(all_continuous() ~ "t.test", all_categorical() ~ "fisher.test"),
    test.args = all_tests("t.test") ~ list(var.equal = TRUE)
  )

Paired t-test and McNemar’s test. The data are expected in long format, one row per participant and treatment, with complete pairs only.

# imagine that each patient received Drug A and Drug B: `id` links the two rows
trial_paired <- trial[c("trt", "marker", "response")]
trial_paired$id <- ave(seq_len(nrow(trial_paired)), trial_paired$trt, FUN = seq_along)

# delete incomplete pairs first
trial_paired <- trial_paired[complete.cases(trial_paired), ]
trial_paired <- trial_paired[trial_paired$id %in% names(which(table(trial_paired$id) == 2)), ]

trial_paired |>
  tbl_summary(
    by = trt,
    include = c(marker, response),
    label = list(marker ~ "Marker Level (ng/mL)", response ~ "Tumor Response"),
    missing = "no"
  ) |>
  add_p(
    test = list(marker ~ "paired.t.test", response ~ "mcnemar.test"),
    group = id
  )

Run a test that is not built in by writing a function that returns a list or one-row data frame with a p.value element. A method element is shown in the footnote. The tests reference describes the arguments the function receives.

ansari <- function(data, variable, by, ...) {
  res <- ansari.test(data[[variable]] ~ as.factor(data[[by]]))
  list(p.value = res$p.value, method = "Ansari-Bradley test")
}

trial |>
  tbl_summary(by = trt, include = c(age, marker), missing = "no") |>
  add_p(test = all_continuous() ~ ansari)

Add a 95% confidence interval around the mean as an extra column. add_ci() puts an interval next to every statistic column; for categorical variables it is the interval of each proportion.

trial |>
  tbl_summary(
    include = c(age, marker),
    statistic = all_continuous() ~ "{mean} ({sd})",
    missing = "no"
  ) |>
  modify_header(stat_0 = "**Mean (SD)**") |>
  remove_footnote_header(stat_0) |>
  add_ci()

Report the difference between two treatment groups, as randomized trials often do. Here the difference in tumor response and in marker level, each with its confidence interval and p-value.

trial |>
  tbl_summary(
    by = trt,
    include = c(response, marker),
    statistic = list(
      all_continuous() ~ "{mean} ({sd})",
      all_categorical() ~ "{p}%"
    ),
    missing = "no"
  ) |>
  add_difference() |>
  add_n() |>
  modify_header(all_stat_cols() ~ "**{level}**")

Compare each group with a single reference group with add_difference_row(): below each variable it adds one row per non-reference level, holding the difference against the reference, its confidence interval and a p-value.

trial |>
  tbl_summary(by = grade, include = c(age, response), missing = "no") |>
  add_difference_row(reference = "I")

Cross and survival tables

Cross-tabulate two variables with tbl_cross(). Margins are added by default; add_p() compares the two variables, and source_note = TRUE moves the p-value below the table.

trial |>
  tbl_cross(row = stage, col = trt, percent = "cell") |>
  add_p(source_note = TRUE) |>
  bold_labels()

Summarize survival curves with tbl_survfit(): the survival probability at chosen times, or the survival time at chosen quantiles. Pass a data frame and it fits one survfit() model per variable; add_p() adds the log-rank test and add_n() and add_nevent() the counts.

library(survival)

trial |>
  tbl_survfit(
    y = Surv(ttdeath, death),
    include = c(trt, grade),
    times = c(12, 24),
    label_header = "**{time} Month**"
  ) |>
  add_p() |>
  add_n() |>
  add_nevent()

trial |>
  tbl_survfit(y = Surv(ttdeath, death), include = trt, probs = 0.5, label_header = "**Median Survival**")

Combining tables

Stratify a summary table by a second variable with tbl_strata(): one table per stratum, merged side by side under a spanning header, or stacked with .combine_with = "tbl_stack". tbl_strata_nested_stack() shows the strata as indented row headers instead.

trial |>
  tbl_strata(
    strata = grade,
    .tbl_fun = ~ .x |> tbl_summary(by = trt, include = age, missing = "no"),
    .header = "**Grade {strata}**, N = {n}"
  )

trial |>
  tbl_strata_nested_stack(
    strata = trt,
    .tbl_fun = ~ .x |> tbl_summary(include = c(age, grade), missing = "no") |>
      modify_header(all_stat_cols() ~ "**Summary Statistics**"),
    row_header = "{strata}, n={n}"
  ) |>
  modify_bold(columns = label, rows = tbl_indent_id1 > 0)

Summarize a continuous variable by two categorical variables with tbl_continuous(): one forms the rows, the other the columns, and each cell holds a statistic of the continuous variable. add_p() compares it across the row variable with a two-way ANOVA.

trial |>
  tbl_continuous(variable = age, by = trt, include = grade) |>
  add_p()