How To Make A Contingency Table In R

5 min read

A contingency table, often referred to as a cross-tabulation or crosstab, serves as a fundamental tool in statistical analysis for summarizing the relationship between two or more categorical variables. In R, creating these tables is straightforward thanks to a rich ecosystem of base functions and specialized packages designed for data manipulation and reporting. Whether you are performing a chi-square test of independence, calculating odds ratios, or simply exploring the distribution of survey responses, mastering the creation of a contingency table in R is an essential skill for any data analyst or researcher Less friction, more output..

Understanding the Basics of Contingency Tables

Before diving into the code, it is helpful to understand what a contingency table actually represents. In practice, at its core, it is a matrix format display of the frequency distribution of variables. In practice, rows typically represent the categories of one variable, while columns represent the categories of another. The cells at the intersection contain the counts (frequencies) of observations that fall into the corresponding combination of categories.

R stores categorical data efficiently using factors. Ensuring your relevant columns are formatted as factors with meaningful levels (labels) rather than raw integers or characters will make your output tables significantly more readable. Which means you can check the structure of your data using the str() function and convert columns using as. factor() or the factor() function to define specific orderings Turns out it matters..

Creating Tables with Base R Functions

The most immediate way to generate a contingency table in R requires no external packages. The table() function is the workhorse for this task. It is versatile, fast, and integrates naturally with other base statistical functions.

The table() Function

The simplest syntax involves passing two vectors (or data frame columns) to the function.

# Sample data creation
survey_data <- data.frame(
  Gender = factor(c("Male", "Female", "Female", "Male", "Female", "Male", "Female", "Male")),
  Preference = factor(c("Tea", "Coffee", "Tea", "Coffee", "Coffee", "Tea", "Tea", "Coffee"))
)

# Basic contingency table
basic_table <- table(survey_data$Gender, survey_data$Preference)
print(basic_table)

This code produces a raw frequency count. By default, table() uses the variable names as dimension names (dimnames), labeling the rows and columns appropriately.

Adding Margins with addmargins()

Raw counts are useful, but analysts often need row totals, column totals, and a grand total to understand the marginal distributions. The addmargins() function appends these sums to the table object It's one of those things that adds up..

# Add row and column totals
table_with_margins <- addmargins(basic_table)
print(table_with_margins)

The output now includes a Sum row and a Sum column, providing immediate context for the cell counts And it works..

Calculating Proportions with prop.table()

Frequencies alone can be misleading if group sizes are unequal. Converting counts to proportions (relative frequencies) allows for fair comparison. The prop.Think about it: table() function calculates these values. The margin argument controls the denominator:

  • margin = NULL (default): Cell count divided by grand total (joint distribution).
  • margin = 1: Cell count divided by row total (conditional distribution of column given row).
  • margin = 2: Cell count divided by column total (conditional distribution of row given column).
# Row proportions (percentages within each Gender)
row_props <- prop.table(basic_table, margin = 1)
# Column proportions (percentages within each Preference)
col_props <- prop.table(basic_table, margin = 2)

# Display as percentages for readability
round(100 * row_props, 1)
round(100 * col_props, 1)

The xtabs() Formula Interface

For users who prefer a formula syntax similar to modeling functions like lm() or glm(), xtabs() offers a powerful alternative. It is particularly useful when working directly with data frames and when you want to specify the relationship using a tilde (~).

People argue about this. Here's where I land on it.

# Formula: ~ Row_Variable + Column_Variable
formula_table <- xtabs(~ Gender + Preference, data = survey_data)
print(formula_table)

This function also handles higher-dimensional arrays effortlessly (e.That's why g. , ~ Var1 + Var2 + Var3), producing a "flat" contingency table object that can be manipulated further Easy to understand, harder to ignore. Less friction, more output..

Leveraging the tidyverse for Modern Workflows

While base R is sufficient for quick analysis, the tidyverse suite of packages—specifically dplyr and tidyr—provides a more readable, pipe-friendly (%>% or |>) workflow. This approach is ideal for integrating table creation into larger data cleaning and transformation pipelines.

Using count() and pivot_wider()

The dplyr::count() function tallies observations by group. Combined with tidyr::pivot_wider(), it reshapes the long-format count data into a wide contingency table format But it adds up..

library(dplyr)
library(tidyr)

tidy_table <- survey_data %>%
  count(Gender, Preference) %>%
  pivot_wider(names_from = Preference, values_from = n, values_fill = 0)

print(tidy_table)

Key advantages of this method:

  1. Explicit handling of zeros: The values_fill = 0 argument ensures that combinations with zero observations appear as 0 rather than NA, which is crucial for many statistical tests.
  2. Tibble output: The result is a tibble (modern data frame), making it easy to pipe directly into ggplot2 for visualization or knitr::kable() for reporting.
  3. Grouped operations: You can easily generate stratified contingency tables by adding a group_by() step before count().

The janitor Package for Presentation-Ready Tables

If your goal is to produce a publication-quality table directly in the console or an R Markdown/Quarto report, the janitor package is indispensable. Its tabyl() function creates a data frame that behaves like a table but prints beautifully with formatting options That's the part that actually makes a difference..

And yeah — that's actually more nuanced than it sounds.

# install.packages("janitor") # Run once if not installed
library(janitor)

clean_table <- survey_data %>%
  tabyl(Gender, Preference) %>%
  adorn_totals("row") %>%      # Add row totals
  adorn_percentages("row") %>% # Calculate row percentages
  adorn_pct_formatting(digits = 1) %>% # Format as percentages
  adorn_ns()                   # Show Ns (counts) alongside percentages

print(clean_table)

The adorn_* family of functions allows you to layer totals, percentages (row, column, or overall), and formatting (rounding, percentage signs) in a single, readable chain. This eliminates the need to manually bind count and proportion matrices together.

Advanced Techniques: Multi-Dimensional and Weighted Tables

Real-world data often requires more complexity than a simple 2x2 table.

Three-Way (and Higher) Tables

Base R handles multi-dimensional tables natively. Both table() and xtabs() accept three or more variables.

# Adding a third variable: AgeGroup
survey_data$AgeGroup <- factor(c("Young", "Old", "Young", "Old", "Young", "Old", "Young", "Old"))

# 3-way table using xtabs
three_way <- xtabs(~ Gender + Preference + AgeGroup, data = survey_data)
print(three_way)

The result is an array. You can slice this array using standard matrix indexing (e.g But it adds up..

Fresh Stories

This Week's Picks

Close to Home

More on This Topic

Thank you for reading about How To Make A Contingency Table In R. We hope the information has been useful. Feel free to contact us if you have any questions. See you next time — don't forget to bookmark!
⌂ Back to Home