Publication Ready Data Tables in R
The R package gt helps researchers produce publication quality data tables. Without using gt, scientists will typically get a bland data table as shown below.
| trt | age | marker | stage | grade | response | death | ttdeath |
|---|---|---|---|---|---|---|---|
| Drug A | 23 | 0.16 | T1 | II | 0 | 0 | 24 |
| Drug B | 9 | 1.107 | T2 | I | 1 | 0 | 24 |
| Drug A | 31 | 0.277 | T1 | II | 0 | 0 | 24 |
| Drug A | NA | 2.067 | T3 | III | 1 | 1 | 17.64 |
| Drug A | 51 | 2.767 | T4 | III | 1 | 1 | 16.43 |
| Drug B | 39 | 0.613 | T4 | I | 0 | 1 | 15.64 |
| Drug A | 37 | 0.354 | T1 | II | 0 | 0 | 24 |
| Drug A | 32 | 1.739 | T1 | I | 0 | 1 | 18.43 |
| Drug A | 31 | 0.144 | T1 | II | 0 | 0 | 24 |
Source: The above is a glimpse of the trial data that comes with the gt package.
Learning Objectives
After this class, participants will know how to:
- Convert standard R data frames into polished, publication-ready tables using the
gtpackage. - Wrangle raw datasets into structured summaries and render them as clean, organized tables.
- Enhance data tables with custom footnotes, styling, and automated statistical summaries using
gtsummary.
Anatomy of a gt Table
Image source: https://gt.rstudio.com
The components of a gt table include:
- The Table Header (optional; with a title and possibly a subtitle)
- The Stub and the Stub Head (optional; contains row labels, optionally within row groups having row group labels and possibly summary labels when a summary is present)
- The Column Labels (contains column labels, optionally under spanner column labels)
- The Table Body (contains columns and rows of cells)
- The Table Footer (optional; possibly with footnotes and source notes)
Turning a Regular Data Table into a GT Table
First load the following packages.
library(gt) ## For constructing GT tables
library(gtsummary) ## For adding descriptive statistics to GT tables
library(tidyverse) ## For data wrangling
Next, load an example dataset that compares two drugs across different disease stages and grades. This dataset comes with the gt package.
data(trial)
Take a look at the trial in R Studio to see what it looks like.
View(trial)
The labels on the bottom of the column headings in the trial data table are attributes. Just doing names(trial) will not show these.
names(trial)
[1] "trt" "age" "marker" "stage" "grade"
[6] "response" "death" "ttdeath"
However, issuing the command below will confirm those as attributes.
str(trial)
tibble [200 × 8] (S3: tbl_df/tbl/data.frame)
$ trt : chr [1:200] "Drug A" "Drug B" "Drug A" "Drug A" ...
..- attr(*, "label")= chr "Chemotherapy Treatment"
$ age : num [1:200] 23 9 31 NA 51 39 37 32 31 34 ...
..- attr(*, "label")= chr "Age"
$ marker : num [1:200] 0.16 1.107 0.277 2.067 2.767 ...
..- attr(*, "label")= chr "Marker Level (ng/mL)"
$ stage : Factor w/ 4 levels "T1","T2","T3",..: 1 2 1 3 4 4 1 1 1 3 ...
..- attr(*, "label")= chr "T Stage"
$ grade : Factor w/ 3 levels "I","II","III": 2 1 2 3 3 1 2 1 2 1 ...
..- attr(*, "label")= chr "Grade"
$ response: int [1:200] 0 1 0 1 1 0 0 0 0 0 ...
..- attr(*, "label")= chr "Tumor Response"
$ death : int [1:200] 0 0 0 1 1 1 0 1 0 1 ...
..- attr(*, "label")= chr "Patient Died"
$ ttdeath : num [1:200] 24 24 24 17.6 16.4 ...
..- attr(*, "label")= chr "Months to Death/Censor"
Tip
To add a label attribute to a data frame column heading use attr(df$column, "label") <- "attribute".
To construct a bare minimum gt table, users will need to provide the input data as an argument to the gt command. The column heading label attributes are used in the gt table. In the code below, rather than enclosing the trial data in the gt() command, it is sent via pipe (%>%).
trial %>% gt()
Add a Title to the Table
The next exercise is to add a title for this gt table by using tab_header and enclose within it the table title.
trial %>% gt() %>% tab_header("Drug Response")
Data Wrangling Task for Answering Questions about the Trial Data
Question 1: What is the overall treatment response for each drug?
To find out, some data wrangling needs to be performed. First, use group_by from tidyverse to group the data by drug (trt) and response. Then, count the type of responses for each drug using summarise from tidyverse. Inside summarise the argument number.responses=n() is included where number.response will be a new column in the resulting table and when set to n() will report the number of each response type according to drug treatment. Next, send the results from group_by and summarise to gt() to create a gt table with the title "Response by Drug" included in tab_header.
trial %>% group_by(trt, response) %>% summarise(number.responses=n()) %>%
gt() %>%
tab_header("Response by Drug")
However, some refinement of the output is needed to make it more legible. Modify the code above to include:
tab_options(row_group.as_column = TRUE): this will move the drugs into the table stub and align tumor response type (seeTumor Responsecolumn) and the number of each response (seenumber.responsecolumn) nicely.cols_labelenables users to re-label the column names in thegt tableoutput. Here, theresponsecolumn will be labeled "Tumor Response" andnumber.of.responsescolumn will be labeled "Number of Responses".
trial %>% group_by(trt,response) %>% summarise(number.of.responses=n()) %>%
gt() %>% tab_options(row_group.as_column = TRUE) %>%
tab_header("Number Responses for each Drug") %>%
cols_label(
response="Tumor Response",
number.of.responses="Number of Responses"
)
Question 2: How does tumor grade and disease stage affect treatment response for each drug?
To find out, some data wrangling using group_by as well as summarise functions from Tidyverse is needed. First use group_by to group the trial data by treatment (trt), grade, stage, and response. Next, use summarise to count the number responses for each combination of treatment (trt), grade, stage, and response. Remember that response is a binary variable so it can be grouped and counted. Do the following afterwards.
trial %>% group_by(trt, grade, stage, response) %>% summarise(number.responses=n()) %>%
gt()
Looking at the gt table list object from the above table confirms that the _row_groups under _stub_df are concatenations of the drug, disease grade and stage.

To set only the drugs as the row labels set groupname_col="trt" inside the gt() command. The resulting table is below.
The gt table list object shows the _row_groups are now just the drugs.

To make the table look even more pleasant, move the group labels (ie. drugs) to their own column by adding the following.
tab_options(row_group.as_column=TRUE): where settingrow_group.as_column=TRUEmoves the group or row labels (ie. drugs) to their own column.tab_header("Drug Response"): adds "Drug Response" as the table title.cols_label: enables customization of column heading labels.cols_align("center"): centers the column headings with respect to the column values.
trial %>% group_by(trt, grade, stage, response) %>%
summarise(number.responses=n()) %>%
gt(groupname_col="trt") %>%
tab_options(row_group.as_column=TRUE) %>%
tab_header("Drug Response") %>% cols_label(
stage="Stage",
response = "Tumor Response",
number.responses = "Number of Responses"
) %>%
cols_align("center")
Question 3: Is there a statistical difference between response for each drug?
To find out, use the gtsummary package, which is an extension to gt that enables addition of descriptive and statistical summaries to data tables.
- In the code below,
trialis piped (%>%) totbl_summaryfrom thegtsummarypackage, which calculates statistics for data. The following arguments are used. by: enables users to set which data variable to stratify or do the statistical comparison for (in this case it would be drug treatment type or thetrtcolumn).include: the dependent variable for which statistical comparison is needed (ie. drug response).missing: tellstbl_summaryhow to handle missing values; setting tonowill not display missing values in the table.type: allows for specifying the dependent variable type. Here, response has two values (0 for non-responders, and 1 for responders), hence it is categorical. The variable is included inside thelistcommand and the variable type specified using the~(ie. the operator that defines a formula object in R).
The output from tbl_summary is then sent to add_p(), which adds p-values to the table. gtsummary will determine the best statistical test based on the data, but users can specify. This output is then sent to as_gt(), which converts a gtsummary object into a gt table. Finally tab_footnote to add custom footnotes. Here, a footnote is added to clarify that 0 and 1 code for non-responder and responder, respectively and location determines where the footnotes are added. Here, the footnote is referring to something in the table body as specified by cells_body and will be placed on the labels column as specified by setting columns=label.
trial %>%
tbl_summary(
by = trt,
include = response,
missing = "no",
type = list(response ~ "categorical")
) %>%
add_p() %>%
as_gt() %>%
tab_footnote(
footnote = "0 = No response; 1 = Responder",
locations = cells_body(columns = label))
Question 4: Does disease grade and stage statistically influence response from each drug?
To solve this question, the gtsummary package will once again be used. In the code below, the trial data is sent via pipe (%>%) to tbl_strata from gtsummary to create a stratified data table.
Terminology
"A stratified data table in gtsummary is a summary table that is split into separate sections or columns by the levels of a grouping variable. In practice, this means gtsummary computes the same descriptive summary within each stratum (ie. treatment group, disease grade, disease stage) so researchers can compare distributions across subgroups." -- as defined by Codex.
The arguments in tbl_strata can be explained as follows.
strata: this argument accepts the argument(s) to stratify the table by. Here, a vector is provided and contains the variablesgradeandstagefrom thetrialdata table. Hence, statistics are computed for each disease grade and stage combination..tbl_fun: this argument tellstbl_stratawhat information to build in the table. In.tbl_fun:~is used to define a formula that tellstbl_stratawhat to compute within each stratified dataset..xis a place holder for the input data.tbl_summary: is used to construct a descriptive statistics summary table. In this argument:byis the grouping variable used to split the table so the descriptive statistics are summarized for each group inby, which in this case istrtor each drug.includeis used to specify which quantitative variable to actually calculate the descriptive statistics on (in this caseresponse).missingwhen set to no will not display missing values in the output.
add_n"adds a column with the total number of non-missing (or missing) observations" -- R documentation.add_ptellstbl_summaryto include statistical test results. By default, the best test for the data is chosen.
trial %>%
tbl_strata(
strata = c(grade, stage),
.tbl_fun = ~ .x %>%
tbl_summary(
by = trt,
include = response,
missing = "no",
) %>%
add_n() %>%
add_p()
)
The resulting table shows disease grade and stage stratified across the top columns, the number of responders (ie. where the response value is 1) for each drug as well as p-value.
Including the following in tbl_strata will create the long version of the above table. Here,
- setting
.combine_withtotbl_stackwill stack all of the individual stratified tables on top of each other. .headerwhen set to{strata}will include the disease grade and stage in the header of each stratified table.
.combine_with = "tbl_stack",
.header = "{strata}"

Next, remove "Tumor Response" from each row and the N values in the column headings labeled "Drug A" and "Drug B" by adding the following:
# This is added inside tbl_summary and removes "Tumor Response" from each row.
label=list(response="")
# This is added after add_p() using %>% to remove the N values under column headings labeled "Drug A" and "Drug B"
# modify_header modifies the table header.
# all_stats_cols() takes all of the numerical columns in the tables and by setting to **{level}** results in the inclusion of "Drug A" and "Drug B" in the column heading since the levels in this table are the two drugs.
modify_header(
all_stat_cols() ~ "**{level}**"
)
The next step is to align the grade and stage heading with the actual response data and remove the column heading labeled "Characteristic".
modify_header(label = "") %>% # where the label in the gtsummary object was Tumor Response, it is now replaced with "".
as_gt() %>% # turns the gtsummary object into a gt table object
tab_options(row_group.as_column = TRUE) %>% # this will put the row labels (ie. disease grade and stage combinations) as a column in the table.
tab_stubhead(label = "Grade, Stage") # tab_stubhead will create a user specified label for the row heading or stub head.