Creating a table in R is a fundamental skill that bridges the gap between raw data and actionable insights. Still, whether you are summarizing survey responses, preparing a contingency table for a chi-square test, or formatting a polished gt table for a publication, R offers a spectrum of tools ranging from base functions to sophisticated packages in the tidyverse ecosystem. Understanding these options allows you to choose the right tool for the specific stage of your data analysis workflow.
Understanding the Basics with Base R
Before diving into external packages, it is essential to master the table() function included in base R. This function is the workhorse for creating frequency tables and contingency tables directly from vectors or data frame columns. It requires no installation and runs instantly, making it ideal for quick exploratory data analysis.
Creating Frequency Tables
A frequency table counts the occurrences of each unique value in a vector. Imagine you have a vector representing the cylinder counts of cars in the mtcars dataset And that's really what it comes down to..
# Load built-in dataset
data(mtcars)
# Simple frequency table
cyl_table <- table(mtcars$cyl)
print(cyl_table)
The output displays the counts for 4, 6, and 8 cylinders. To convert these raw counts into proportions—a common requirement for reporting—wrap the result in prop.Now, you can immediately see the distribution of your categorical variable. table() Simple, but easy to overlook..
# Proportions
prop.table(cyl_table)
# Percentages (rounded)
round(prop.table(cyl_table) * 100, 1)
Building Contingency Tables (Cross-Tabulation)
The real power of the base table() function shines when you pass two or more vectors. This creates a contingency table (or cross-tabulation), showing the joint distribution of two categorical variables.
# Cross-tabulation of Cylinders vs Transmission (am: 0=auto, 1=manual)
ct_table <- table(mtcars$cyl, mtcars$am)
print(ct_table)
By default, the output lacks descriptive labels. You can improve readability using the dnn argument (dimension names) or by converting the vectors to factors with meaningful labels beforehand.
# Adding dimension names
table(mtcars$cyl, mtcars$am, dnn = c("Cylinders", "Transmission"))
Adding Margins and Summary Statistics
For a complete picture, analysts often need row totals, column totals, and grand totals. The addmargins() function appends these sums effortlessly.
# Add margins (sums by default)
addmargins(ct_table)
# Add margins with custom functions (e.g., mean, sd) - requires an array
# Note: addmargins works best on tables created by table() or xtabs()
Another powerful base function is xtabs(), which uses a formula interface (~ row + col) and works without friction with data frames. It is often preferred for complex experimental designs Most people skip this — try not to. Nothing fancy..
# Formula interface
xtabs(~ cyl + am, data = mtcars)
The Tidyverse Approach: dplyr and tidyr
While base R is fast for counting, the tidyverse philosophy integrates table creation into a data manipulation pipeline. This approach is superior when you need to filter, group, or calculate multiple summary statistics (mean, median, standard deviation) before presenting the final table Small thing, real impact..
The official docs gloss over this. That's a mistake.
Summarizing with dplyr::count() and group_by()
The count() verb is the direct tidyverse equivalent of table(), but it returns a tibble (a modern data frame) rather than a table object. This makes the output easier to pipe into ggplot2 or further dplyr transformations.
library(dplyr)
# Simple count
mtcars %>%
count(cyl)
# Count with weights (e.g., weighted by mpg)
mtcars %>%
count(cyl, wt = mpg)
For more complex summaries, combine group_by() with summarise(). This allows you to build "summary tables" containing means, standard deviations, and sample sizes for numeric variables across groups.
summary_table <- mtcars %>%
group_by(cyl, am) %>%
summarise(
n = n(),
mean_mpg = mean(mpg),
sd_mpg = sd(mpg),
min_mpg = min(mpg),
max_mpg = max(mpg),
.groups = "drop" # Prevents rowwise grouping in output
)
print(summary_table)
Reshaping Data with tidyr::pivot_wider()
Often, the "tidy" long format produced by summarise() needs to be widened for human readability. pivot_wider() transforms key-value pairs into a wide table format, effectively creating the final presentation structure.
library(tidyr)
wide_table <- summary_table %>%
pivot_wider(
names_from = am,
values_from = c(mean_mpg, sd_mpg, n),
names_glue = "{.value}_am{am}" # Custom column naming
)
print(wide_table)
This workflow—Group -> Summarise -> Pivot—is the industry standard for creating custom analytical tables in R That's the whole idea..
Specialized Packages for Statistical Tables
When preparing manuscripts for academic journals or formal reports, standard data frames often lack the formatting nuances required (e.g., significance stars, merged headers, specific decimal alignment). Several packages specialize in this "last mile" of table production Easy to understand, harder to ignore..
gtsummary: Publication-Ready in Seconds
The gtsummary package is arguably the highest return-on-investment tool for researchers. It creates beautiful, publication-ready descriptive tables (Table 1) and regression model tables with minimal code. It handles variable labels, missing data counts, and statistical tests automatically.
# install.packages("gtsummary")
library(gtsummary)
# Descriptive table (Table 1)
tbl_summary(
data = mtcars %>% select(mpg, cyl, disp, hp, wt, am),
by = am, # Stratify by transmission
statistic = list(all_continuous() ~ "{mean} ({sd})"),
missing = "no"
) %>%
add_p() %>% # Add p-values for group comparisons
add_overall() %>% # Add overall column
bold_labels()
The output is an object that renders beautifully in R Markdown, Quarto, and Shiny apps, supporting HTML, LaTeX, and Word output natively Still holds up..
modelsummary: The Universal Model Table
If your primary need is comparing regression models side-by-side, modelsummary is the modern standard. It supports hundreds of model types (lm, glm, lme4, brms, fixest, etc.) and offers extreme customization.
# install.packages("modelsummary")
library(modelsummary)
mod1 <- lm(mpg ~ cyl + hp, data = mtcars)
mod2 <- lm(mpg ~ cyl + hp + wt, data = mtcars)
modelsummary(list("Base" = mod1, "Full" = mod2),
fmt = 3,
stars = TRUE,
gof_map = c("n", "r.squared", "rmse"))
gt and flextable: Pixel-Perfect Formatting
For absolute control over every border, font, color, and cell merge, gt (Grammar of Tables) and flextable are the two titans Small thing, real impact. Still holds up..
gtuses a
a grammar-of-tables approach, allowing you to build tables layer by layer using an expressive pipe syntax. You can define column labels, apply conditional formatting, insert foot
gt: Building Tables with a Grammar
gt adopts a grammar‑of‑tables philosophy, where each layer you add—column definitions, row groups, cell formatting, or footnote insertion—acts like a sentence in a language. This modularity makes it exceptionally transparent: you can inspect the intermediate objects, tweak a single component, and re‑render without starting over And that's really what it comes down to..
No fluff here — just what actually works.
library(gt)
# Start with a simple data frame
df_gt <- tibble(
model = c("Base", "Full"),
r_squared = c(0.68, 0.73),
adj_r_squared = c(0.66, 0.72),
rmse = c(3.92, 3.41)
)
# Create the table and apply custom formatting
gt_tbl <- df_gt %>%
gt(rowlabels = "model") %>%
fmt_number(columns = c(r_squared, adj_r_squared, rmse),
decimals = 3) %>%
tab_header(title = "Model Performance Comparison") %>%
tab_source(note = "Data derived from mtcars.") %>%
opt_align_table_header(center = TRUE) %>%
as_raw_html()
A few standout features include:
- Conditional formatting – highlight cells exceeding a threshold using
fmt_color(). - Cell merging – combine header rows or summary cells with
tab_options()andcell_merge(). - Rich footnotes and sources – embed citations, methodology notes, or data provenance directly in the footer.
- Output flexibility – naturally export to HTML, LaTeX (
kableExtra‑style), PDF (gt’s own PDF engine), or even Microsoft Word via thegt‑Word add‑in.
If you need to produce a table that matches a journal’s exact layout—multiple spanning columns, custom fonts, or precise decimal alignment—gt gives you the brushstrokes to paint that picture pixel‑perfectly.
flextable: Spreadsheet‑Ready Tables for Word
While gt shines in a declarative workflow, flextable feels more like working in Excel: you manipulate rows, columns, and cell properties directly. This orientation makes it a favorite when the final artifact must live in a Word document, especially for reports that undergo frequent manual edits.
library(flextable)
# Build a flextable from a data frame
ft <- flextable(df_gt) %>%
autofit() %>%
bold() %>% # Bold header row
set_table_properties( # Set overall table style
font_family = "Arial",
font_size = 10
) %>%
add_header_level(level = 1,
content = "Model Performance") %>%
add_footer_lines("Source: mtcars dataset, processed in R.") %>%
border(horizontal = T, color = "black", width = 0.5)
# Export to Word (requires `msword` or `winword` on Windows)
# print(ft, file = "model_perf.docx")
Key capabilities of flextable include:
- Direct cell editing – functions like
cellText(),cellFont(), andcellMerge()let you target specific coordinates. - Integrated Word styling – apply paragraph styles, shading, and embedded objects as you would in any Word
document. Worth adding: this makes it incredibly practical for business analysts who need to tweak tables directly in Word before finalizing a report. Unlike gt, which prioritizes HTML-based output, flextable excels at producing dense, spreadsheet-like tables that translate smoothly into Word documents without requiring an internet browser. When dealing with complex layouts—such as merged cells, multi-level headers, or specific font embeddings—flextable provides granular control over every cell, ensuring the final document looks exactly as intended by the analyst.
Some disagree here. Fair enough.
In the long run, the choice between gt and flextable comes down to your audience and medium. If you are sharing results on a website, generating PDFs for publication, or leveraging a purely declarative pipeline, gt is your ideal companion. Conversely, if your end goal is a Word document that requires frequent manual adjustments by non-technical
users, then flextable is the way to go. Its Excel-like interface and granular control over cell properties make it an excellent choice for collaborative environments where stakeholders may need to adjust the table without touching the underlying code That's the part that actually makes a difference..
Pulling it all together, both gt and flextable are powerful tools in the R ecosystem for creating publication-ready tables. gt offers a declarative, pipeline-oriented approach that excels in web and print contexts, while flextable provides a more interactive, spreadsheet-like experience tailored for Word documents and frequent manual edits. Your choice should align with your output format and the level of collaboration required, as each package is designed to meet distinct needs in data presentation Not complicated — just consistent..
Honestly, this part trips people up more than it should.