Intro to R Course
  • Prepare for the course
  • Copyright
  • Practical Sessions
  • Resources
  • Source Code
  • Report an issue
  1. Session 4
  2. The whole table in one line
  • Welcome
  • Session 1
    • Getting familiar with RStudio
    • Setting up your Workspace
    • Functions that make the work
  • Session 2
    • Data manipulation using the Tidyverse
    • Filtering rows
    • Creating variables
    • Grouping and summarising
  • Session 3
    • Intro to Data Cleaning
    • Variable Class
    • Recoding variables
    • Derived Variables & Export
  • Session 4
    • Counting cases
    • Crosstabulations and richer tables
    • Tables of things you cannot count
    • The whole table in one line

On this page

  • Before you start
  • Part 7 · Table 1 with a line of code
    • Sometimes it’s just too much
    • Making better choices
  • Part 8 · Comparing two groups
    • More than two groups
  • Part 9 · Polish and Export
    • Export your final table
  • Exercise Summary
  1. Session 4
  2. The whole table in one line

The whole table in one line

Session 4 practical exercises

Before you start

Continue working on the same script and make sure gtsummary is loaded along with the rest of libraries in p_load().

Open any surveillance report, any epidemiological paper, any outbreak investigation. Somewhere near the beginning there is a table describing the population under study: age, sex, the main exposures, the main outcomes, sometimes split into two columns to compare groups. It is so predictable that everyone simply calls it Table 1.

You could build one right now with what you learned in the previous exercises. Numeric variables need a median and a range, categorical variables need counts and percentages, and each of them needs its own summarise() before you stack everything together. It would work. It would also take you the rest of the afternoon.

Which is why someone wrote a package for it.

Part 7 · Table 1 with a line of code

Sometimes it’s just too much

Today we will use the single most important function of gtsummary: tbl_summary(). As always, start by looking at the function documentation to have a general idea about its arguments and potential. But let’s start step by step:

Action — Pass the whole dataset to tbl_summary() and look at what comes back. It’s a tidy-friendly function, so you know what to do. You don’t need to assign any object yet

TipBetter visualize the output

The resulting table is large enough that you need to scroll down the viewer pane to see all table rows. If you want a better glance of the table, you can always click on the ‘Show in new window’ button next to the broom. Since tbl_summary() produces an HTML object, it will open in your browser

Show in new window button (click to enlarge)

Two things are true about that output at the same time.

  1. It is impressive: without telling it anything, the function went through every column, worked out whether it was numeric or categorical, chose a median with its interquartile range for the first kind and counts with percentages for the second, counted the missing values separately, and formatted the whole thing.

  2. It is also useless. Dates, identifiers, variables nobody asked about and are not relevant for the report, all summarised with equal enthusiasm. Nobody can read it and nobody would put it in a report.

The problem is not the function. The problem is that you gave it everything you had.

Making better choices

tbl_summary() summarises every column it receives. That single sentence tells you where the real work is: not in the table function, but in deciding what reaches it. And that decision is not a technical one. Which variables belong in Table 1 is an epidemiological question about what your reader needs to understand the population — nobody, and no package, can answer it for you.

Action — Using select(), keep only age_years, sex, clinical_presentation, death and region_id, then build the summary again. Assign it to imd_tbl.

Readable, complete, and produced by two functions. Notice what it did with age_years compared to the other four: it recognized the numeric variable and gave it a median, while the rest got counts and percentages. It also picked up the labels straight from your column names.

Part 8 · Comparing two groups

Kassandra’s request lands mid-afternoon: “Can we see it split by sex?”

Before changing anything, check the variable you are about to split by. A grouping variable full of unknowns will produce a column of unknowns. Since you created imd_tbl before, it’s easy to judge

Action — Now rebuild the summary adding the argument by = sex. Pay attention to any message or warning after executing the code.

Look at what happened to sex itself: it is no longer a row. It became the columns, and every other variable is now described separately within each of them. One argument turned a description into a comparison.

NoteThe same idea, again

You have now met this three times in one session. In tabyl(age_group, year) the second variable became columns. In pivot_wider() the values of year became columns. Here, the levels of sex become columns.

Different packages, different syntax, same underlying move: take the categories of one variable and turn them into the width of the table. Once you recognise the move, the syntax is a detail you can look up.

Action — Can you spot any meaningful difference between groups in any variable?

NoteMeaningful or significant differences?

The age_years variable looks a bit different in males compared with females, especially because of the interquartile range. Male cases could have a lower median age. But only with a descriptive table we cannot state anything

Action — Pipe the table into add_p() and look at the new column.

WarningBefore you use that column

add_p() is one line of code and it will change how people read your table. It deserves more thought than it costs.

The function chose a statistical test for you, based on the type of each variable. It did not ask what you were testing, whether you had a hypothesis, or whether comparing every variable at once was a sensible thing to do. It cannot: it has no idea what your study is about.

Two habits will protect you here. First, know which test was run for each row before you show the table to anyone — add_p() documents its choices, and you can override them. Second, ask yourself whether you actually want a p-value in a descriptive table. Describing who got sick and testing a hypothesis are different jobs, and a column of asterisks invites your reader to confuse them.

For today, the point is narrower: the easier a statistic is to produce, the more deliberate you have to be about producing it.

More than two groups

tbl_summary() also incorporate test that compare more than two categories. Let’s try with region_id and the same set of variables:

Action — Copy the code from your last table with p-values, but change the grouping variable to region_id. Assign the result to imd_table_region and check the result

Part 9 · Polish and Export

Our main table imd_tbl is right, but it is not finished. Two small additions turn it into something you can actually consider sharing.

Action — Add a new argument, missing = "no", inside tbl_summary() and compare the result with the previous version.

The rows counting unknowns disappear. Use this when the missing values are few and already reported elsewhere — and not when they are part of the story. Hiding a variable that is 40% missing does not make it less missing, it only makes it invisible to your reader.

Action — Pipe the table into bold_labels().

One last thing. Probably you are already thinking about it because of the last exercise. What about variable names in the table?

Action — Considering the code for this table, would you still rely on rename() for this task? why?

CautionOrder matters

When improving our previous table, we explained how renaming at the very end fixed the table appearance easily, and you could use complex variable names not affecting their use because the data was already in table format.

Now, the tbl_summary() function is making use of variable names (sex or region_id), therefore changing any of them can have an impact, and may require the use of backticks i.e., for the label Age (Years). Therefore, you need to think twice about the convenience of rename() in this, or similar situations

Luckily for you, tbl_summary() already has the solution to modify variable labels (names on the table) without actually having to change variable names.

Action — Go to the function help page and look for the argument label. Read it: would you be able to use it?

It’s a bit complicated for someone in a R intro course. The argument takes a list of pairs var name = "desired label") as argument. It’s normal that you did not understand the explanation with your base level of R, but it’s good that you can slowly familiarize yourself with this approach as it is quite common across many functions.

Here is the answer, and this is how you use lists of arguments:

imd_tbl <- imd %>% 
  select(age_years, sex, clinical_presentation, death, region_id) %>% 
  tbl_summary(by = sex,
              missing = "no",
              label = list(
                age_years             ~ "Age (years)",
                clinical_presentation ~ "Clinical presentation",
                death                 ~ "Died",
                region_id             ~ "Region"
              )) %>% 
  add_p() %>%
  bold_labels() 

Action — Add the label argument to your tbl_summary() call

Export your final table

Your gtsummary table is neither a dataframe nor a flextable.

Action — Check its class.

To export it you first translate it into something flextable understands, and from there the road is the one you already walked twice today.

Action — Convert the table with as_flex_table(), apply autofit(), and save it to your outputs folder as a Word document. Assign the result to an object imd_table_4 and then save it.

As you saw, gtsummary has its own version of the flextable() function. You could have very well used it and the result would have been the same, but when libraries implement their own version it is wise to use it. You never know which improvements or specific options they incorporated.

Other than that, hopefully exporting a table to a Word document is no longer something exotic, but a repetitive step.


Why do you need select() before tbl_summary()?

What does the argument by = sex do in tbl_summary()?

What kind of object does tbl_summary() return?

Exercise Summary

Look back at where this session started. You were counting one variable with a function that returned an object you could not use. You are finishing it by producing a complete, publication-shaped, group-comparing descriptive table with about five lines of code in the simplest versions.

Kassandra is quite satisfied with the tables you produced for the meeting. For now, the working group is satisfied with the results, but sooner than later a formal report will be needed, and tables alone are a poor way of communicating with colleagues, the press or the general public. Get ready, because tomorrow you will produce your first epidemiological graphs, and learn one of the most powerful tools of R: ggplot2

ImportantThe order in which you learned these things was not an accident

tbl_summary() makes dozens of decisions for you — which statistic for which variable type, how to format the percentages, what to do with the unknowns, which test to run. Every one of those decisions is one you now know how to make yourself, because you spent three exercises making them by hand. When the automatic table looks wrong, you will know where to look; when a reviewer asks how a number was calculated, you will be able to answer.

Had we started here, you would have a table you could not defend. That is the difference between using a tool and depending on one, and this applies to every aspect of programming as you will almost always find a library or a function that automates something for you. For instance, creating age groups is super simple thanks to libraries like epikit::age_categories(). But it’s always better to know how to do it yourself, as every function has limitations

Function Package What it does
tbl_summary() gtsummary Builds a descriptive table, choosing statistics by variable type
by = gtsummary Splits the table into columns by the levels of a variable
missing = "no" gtsummary Hides the rows counting missing values
labels = list() gtsummary Format the name of variables in the table
bold_labels() gtsummary Puts the variable labels in bold
add_p() gtsummary Adds a column of p-values, choosing the test automatically
as_flex_table() gtsummary Converts the table into a flextable so it can be exported
Tip💡 Show solution — only after trying yourself!
# Everything at once: impressive and unusable
imd %>% 
  tbl_summary()

# Choosing what belongs in the table
imd_tbl <- imd %>% 
  select(age_years, sex, clinical_presentation, death, region_id) %>% 
  tbl_summary()

imd_tbl

# Splitting by sex
imd_tbl <- imd %>% 
  select(age_years, sex, clinical_presentation, death, region_id) %>% 
  tbl_summary(by = sex)

imd_tbl

# Compare groups
imd_tbl <- imd %>% 
  select(age_years, sex, clinical_presentation, death, region_id) %>% 
  tbl_summary(by = sex) %>% 
  add_p()

imd_tbl

# More than two groups
imd_table_region <- imd %>% 
  select(age_years, sex, clinical_presentation, death, region_id) %>% 
  tbl_summary(by = region_id) %>% 
  add_p()

imd_table_region

# Polished version
imd_tbl <- imd %>% 
  select(age_years, sex, clinical_presentation, death, region_id) %>% 
  tbl_summary(by = sex,
              missing = "no") %>% 
  add_p() %>%
  bold_labels() 

imd_tbl

# With variable labels
imd_tbl <- imd %>% 
  select(age_years, sex, clinical_presentation, death, region_id) %>% 
  tbl_summary(by = sex,
              missing = "no",
              label = list(
                age_years             = "Age (years)",
                clinical_presentation = "Clinical presentation",
                death                 = "Died",
                region_id             = "Region"
              )) %>% 
  add_p() %>%
  bold_labels() 

imd_tbl

# Check object class
class(imd_tbl)

# Export to Word
imd_table_4 <- imd_tbl %>% 
  as_flex_table() %>% 
  autofit()

save_as_docx(imd_table_4, path = here("outputs", "Table_4.docx"))
Tables of things you cannot count
Source Code
---
title: "The whole table in one line"
subtitle: "Session 4 practical exercises"
---

```{r}
#| include: false
library(webexercises)
library(pacman)
p_load(rio, here, tidyverse, janitor, flextable, gtsummary)

imd <- import(here("data", "clean", "IMD_Sample_Clean.rds"))
```

## Before you start

Continue working on the same script and make sure `gtsummary` is loaded along with the rest of libraries in `p_load()`.

Open any surveillance report, any epidemiological paper, any outbreak investigation. Somewhere near the beginning there is a table describing the population under study: age, sex, the main exposures, the main outcomes, sometimes split into two columns to compare groups. It is so predictable that everyone simply calls it **Table 1**.

You could build one right now with what you learned in the previous exercises. Numeric variables need a median and a range, categorical variables need counts and percentages, and each of them needs its own `summarise()` before you stack everything together. It would work. It would also take you the rest of the afternoon.

Which is why someone wrote a package for it.

## Part 7 · Table 1 with a line of code

### Sometimes it's just too much

Today we will use the single most important function of `gtsummary`: `tbl_summary()`. As always, start by looking at the function documentation to have a general idea about its arguments and potential. But let's start step by step:

**Action** — Pass the whole dataset to `tbl_summary()` and look at what comes back. It's a tidy-friendly function, so you know what to do. You don't need to assign any object yet

::: callout-tip
## Better visualize the output

The resulting table is large enough that you need to scroll down the viewer pane to see all table rows. If you want a better glance of the table, you can always click on the 'Show in new window' button next to the broom. Since `tbl_summary()` produces an HTML object, it will open in your browser
:::

::: column-margin
Show in new window button (click to enlarge)

![](/images/exercises/S4_E4_1.png){.lightbox}
:::

Two things are true about that output at the same time.

1.  It is **impressive**: without telling it anything, the function went through every column, worked out whether it was numeric or categorical, chose a median with its interquartile range for the first kind and counts with percentages for the second, counted the missing values separately, and formatted the whole thing.

2.  It is also **useless**. Dates, identifiers, variables nobody asked about and are not relevant for the report, all summarised with equal enthusiasm. Nobody can read it and nobody would put it in a report.

The problem is not the function. The problem is that you gave it everything you had.

### Making better choices

`tbl_summary()` summarises every column it receives. That single sentence tells you where the real work is: not in the table function, but in deciding what reaches it. And that decision is not a technical one. Which variables belong in Table 1 is an epidemiological question about what your reader needs to understand the population — nobody, and no package, can answer it for you.

**Action** — Using `select()`, keep only `age_years`, `sex`, `clinical_presentation`, `death` and `region_id`, then build the summary again. Assign it to `imd_tbl`.

Readable, complete, and produced by two functions. Notice what it did with `age_years` compared to the other four: it recognized the numeric variable and gave it a median, while the rest got counts and percentages. It also picked up the labels straight from your column names.

## Part 8 · Comparing two groups

Kassandra's request lands mid-afternoon: *"Can we see it split by sex?"*

Before changing anything, check the variable you are about to split by. A grouping variable full of unknowns will produce a column of unknowns. Since you created `imd_tbl` before, it's easy to judge

**Action** — Now rebuild the summary adding the argument `by = sex`. Pay attention to any message or warning after executing the code.

Look at what happened to `sex` itself: it is no longer a row. It became the **columns**, and every other variable is now described separately within each of them. One argument turned a description into a comparison.

::: callout-note
## The same idea, again

You have now met this three times in one session. In `tabyl(age_group, year)` the second variable became columns. In `pivot_wider()` the values of `year` became columns. Here, the levels of `sex` become columns.

Different packages, different syntax, same underlying move: **take the categories of one variable and turn them into the width of the table**. Once you recognise the move, the syntax is a detail you can look up.
:::

**Action** — Can you spot any meaningful difference between groups in any variable?

::: {.callout-note collapse="true" appearance="simple" icon="false"}
## Meaningful or significant differences?

The `age_years` variable looks a bit different in males compared with females, especially because of the interquartile range. Male cases could have a lower median age. But only with a descriptive table we cannot state anything
:::

**Action** — Pipe the table into `add_p()` and look at the new column.

::: callout-warning
## Before you use that column

`add_p()` is one line of code and it will change how people read your table. It deserves more thought than it costs.

The function chose a statistical test for you, based on the type of each variable. It did not ask what you were testing, whether you had a hypothesis, or whether comparing every variable at once was a sensible thing to do. It cannot: it has no idea what your study is about.

Two habits will protect you here. First, know which test was run for each row before you show the table to anyone — `add_p()` documents its choices, and you can override them. Second, ask yourself whether you actually want a p-value in a **descriptive** table. Describing who got sick and testing a hypothesis are different jobs, and a column of asterisks invites your reader to confuse them.

For today, the point is narrower: the easier a statistic is to produce, the more deliberate you have to be about producing it.
:::

### More than two groups

`tbl_summary()` also incorporate test that compare more than two categories. Let's try with `region_id` and the same set of variables:

**Action** — Copy the code from your last table with p-values, but change the grouping variable to `region_id`. Assign the result to `imd_table_region` and check the result

## Part 9 · Polish and Export

Our main table `imd_tbl` is right, but it is not finished. Two small additions turn it into something you can actually consider sharing.

**Action** — Add a new argument, `missing = "no"`, inside `tbl_summary()` and compare the result with the previous version.

The rows counting unknowns disappear. Use this when the missing values are few and already reported elsewhere — and *not* when they are part of the story. Hiding a variable that is 40% missing does not make it less missing, it only makes it invisible to your reader.

**Action** — Pipe the table into `bold_labels()`.

One last thing. Probably you are already thinking about it because of the last exercise. What about **variable names** in the table?

**Action** — Considering the code for this table, would you still rely on `rename()` for this task? why?

::: {.callout-caution collapse="true" appearance="simple"}
## Order matters

When improving our previous table, we explained how renaming at the very end fixed the table appearance easily, and you could use complex variable names not affecting their use because the data was already in table format.

Now, the `tbl_summary()` function is making use of variable names (`sex` or `region_id`), therefore changing any of them can have an impact, and may require the use of backticks i.e., for the label `Age (Years)`. Therefore, you need to think twice about the convenience of `rename()` in this, or similar situations
:::

Luckily for you, `tbl_summary()` already has the solution to modify variable labels (names on the table) without actually having to change variable names.

**Action** — Go to the function help page and look for the argument `label`. Read it: would you be able to use it?

It's a bit complicated for someone in a R intro course. The argument takes a list of pairs `var name = "desired label")` as argument. It's normal that you did not understand the explanation with your base level of R, but it's good that you can slowly familiarize yourself with this approach as it is quite common across many functions.

Here is the answer, and this is how you use lists of arguments:

``` r
imd_tbl <- imd %>% 
  select(age_years, sex, clinical_presentation, death, region_id) %>% 
  tbl_summary(by = sex,
              missing = "no",
              label = list(
                age_years             ~ "Age (years)",
                clinical_presentation ~ "Clinical presentation",
                death                 ~ "Died",
                region_id             ~ "Region"
              )) %>% 
  add_p() %>%
  bold_labels() 
```

**Action** — Add the `label` argument to your `tbl_summary()` call

### Export your final table

Your `gtsummary` table is neither a dataframe nor a flextable.

**Action** — Check its class.

To export it you first translate it into something `flextable` understands, and from there the road is the one you already walked twice today.

**Action** — Convert the table with `as_flex_table()`, apply `autofit()`, and save it to your `outputs` folder as a Word document. Assign the result to an object `imd_table_4` and then save it.

As you saw, `gtsummary` has its own version of the `flextable()` function. You could have very well used it and the result would have been the same, but when libraries implement their own version it is wise to use it. You never know which improvements or specific options they incorporated.

Other than that, hopefully exporting a table to a Word document is no longer something exotic, but a repetitive step.

------------------------------------------------------------------------

```{r}
#| echo: false
opts_1 <- c(
  "Because `tbl_summary()` can only handle four variables at a time",
  answer = "Because `tbl_summary()` summarises every column it receives, so the choice of variables is made before it",
  "Because `select()` is needed to convert the variables into a format `tbl_summary()` accepts",
  "Because otherwise the numeric variables would be summarised as categorical"
)
```

**Why do you need `select()` before `tbl_summary()`?**

`r longmcq(opts_1)`

```{r}
#| echo: false
opts_2 <- c(
  "It sorts the rows of the table by the values of `sex`",
  "It keeps only the cases of one sex and discards the rest",
  answer = "It turns the levels of `sex` into columns and describes every other variable within each of them",
  "It adds `sex` as an extra row at the top of the table"
)
```

**What does the argument `by = sex` do in `tbl_summary()`?**

`r longmcq(opts_2)`

```{r}
#| echo: false
opts_3 <- c(
  "It is a flextable, so it can be exported directly with `save_as_docx()`",
  "It is a dataframe, so it can be manipulated with tidy verbs",
  answer = "It is a gtsummary object, and it must be converted with `as_flex_table()` before exporting",
  "It is a list, in the same way a three-variable `tabyl()` is"
)
```

**What kind of object does `tbl_summary()` return?**

`r longmcq(opts_3)`

## Exercise Summary

Look back at where this session started. You were counting one variable with a function that returned an object you could not use. You are finishing it by producing a complete, publication-shaped, group-comparing descriptive table with about five lines of code in the simplest versions.

Kassandra is quite satisfied with the tables you produced for the meeting. For now, the working group is satisfied with the results, but sooner than later a formal report will be needed, and tables alone are a poor way of communicating with colleagues, the press or the general public. Get ready, because tomorrow you will produce your first epidemiological graphs, and learn one of the most powerful tools of R: `ggplot2`

::: callout-important
## The order in which you learned these things was not an accident

`tbl_summary()` makes dozens of decisions for you — which statistic for which variable type, how to format the percentages, what to do with the unknowns, which test to run. Every one of those decisions is one you now know how to make yourself, because you spent three exercises making them by hand. When the automatic table looks wrong, you will know where to look; when a reviewer asks how a number was calculated, you will be able to answer.

Had we started here, you would have a table you could not defend. That is the difference between using a tool and depending on one, and this applies to every aspect of programming as you will almost always find a library or a function that automates something for you. For instance, creating age groups is super simple thanks to libraries like `epikit::age_categories()`. But it's always better to know how to do it yourself, as every function has limitations
:::

| Function | Package | What it does |
|------------------------|-------------------|-----------------------------|
| `tbl_summary()` | gtsummary | Builds a descriptive table, choosing statistics by variable type |
| `by =` | gtsummary | Splits the table into columns by the levels of a variable |
| `missing = "no"` | gtsummary | Hides the rows counting missing values |
| `labels = list()` | gtsummary | Format the name of variables in the table |
| `bold_labels()` | gtsummary | Puts the variable labels in bold |
| `add_p()` | gtsummary | Adds a column of p-values, choosing the test automatically |
| `as_flex_table()` | gtsummary | Converts the table into a flextable so it can be exported |

::: {.callout-tip collapse="true"}
## 💡 Show solution — only after trying yourself!

``` r
# Everything at once: impressive and unusable
imd %>% 
  tbl_summary()

# Choosing what belongs in the table
imd_tbl <- imd %>% 
  select(age_years, sex, clinical_presentation, death, region_id) %>% 
  tbl_summary()

imd_tbl

# Splitting by sex
imd_tbl <- imd %>% 
  select(age_years, sex, clinical_presentation, death, region_id) %>% 
  tbl_summary(by = sex)

imd_tbl

# Compare groups
imd_tbl <- imd %>% 
  select(age_years, sex, clinical_presentation, death, region_id) %>% 
  tbl_summary(by = sex) %>% 
  add_p()

imd_tbl

# More than two groups
imd_table_region <- imd %>% 
  select(age_years, sex, clinical_presentation, death, region_id) %>% 
  tbl_summary(by = region_id) %>% 
  add_p()

imd_table_region

# Polished version
imd_tbl <- imd %>% 
  select(age_years, sex, clinical_presentation, death, region_id) %>% 
  tbl_summary(by = sex,
              missing = "no") %>% 
  add_p() %>%
  bold_labels() 

imd_tbl

# With variable labels
imd_tbl <- imd %>% 
  select(age_years, sex, clinical_presentation, death, region_id) %>% 
  tbl_summary(by = sex,
              missing = "no",
              label = list(
                age_years             = "Age (years)",
                clinical_presentation = "Clinical presentation",
                death                 = "Died",
                region_id             = "Region"
              )) %>% 
  add_p() %>%
  bold_labels() 

imd_tbl

# Check object class
class(imd_tbl)

# Export to Word
imd_table_4 <- imd_tbl %>% 
  as_flex_table() %>% 
  autofit()

save_as_docx(imd_table_4, path = here("outputs", "Table_4.docx"))
```
:::

```{=html}
<script>
document.addEventListener("DOMContentLoaded", function() {
  var radiogroups = document.getElementsByClassName("webex-radiogroup");
  for (var i = 0; i < radiogroups.length; i++) {
    radiogroups[i].onchange = radiogroups_func;
  }
});
</script>
```

© 2026 – Intro to R Course