Skip to main content
v2026.11,858 entries · CC-BY 4.0

Crosstabs in R: table(), xtabs(), CrossTable() and tabyl()

How to make a crosstab in R four ways, with percentages, missing-value settings, an SPSS-style layout and a chi-square test on the result.

Written and maintained by CASRAI Editorial Board

Last updated

A crosstab (a contingency table) counts how many rows of your data fall into each combination of two or more categorical variables. R has four common ways to build one: table() and xtabs() in the standard distribution, CrossTable() in the gmodels package and tabyl() in the janitor package. They differ mainly in how they treat missing values, whether they can start from already-aggregated counts, and how much of the SPSS or SAS crosstab layout they reproduce. This guide gives the documented call for each, then shows how to turn counts into percentages and run a chi-square test on the result.

What crosstabs are used for in research

Crosstabs summarise the relationship between two categorical variables, for example treatment group by outcome, or survey response by respondent type. They are the input to tests of independence such as the chi-square test and to association measures such as Cramér’s V and the phi coefficient. If you are moving from SPSS, the guide to reading SPSS output explains the Crosstabs layout that CrossTable() can imitate.

Install or access (official source only)

R itself comes from CRAN (cran.r-project.org). Two of the four routes need no extra installation: table() is documented in the base package and xtabs() in the stats package, both of which load when R starts. The other two come from CRAN packages:

install.packages("gmodels")   # CrossTable()
install.packages("janitor")   # tabyl()
library(gmodels)
library(janitor)

The gmodels package page is cran.r-project.org/package=gmodels; the janitor reference for tabyl() is at sfirke.github.io/janitor. The examples below use mtcars, a data set that ships with R, so they run as written.

Step-by-step: build a crosstab in R

1. Two variables from a data frame with table()

The R documentation describes table() as building “a contingency table of counts for each combination of factor levels”. Pass the two variables and name the dimensions with dnn:

tbl <- table(mtcars$cyl, mtcars$gear, dnn = c("cyl", "gear"))
tbl

2. The same table with xtabs()

xtabs() takes a formula. The right-hand side lists the cross-classifying variables separated by +; the documentation states that interactions are not allowed.

tbl2 <- xtabs(~ cyl + gear, data = mtcars)
tbl2

3. Start from counts you already have

If your data are already aggregated, put the count column on the left of the formula. The xtabs() help page uses this pattern with the built-in UCBAdmissions data:

DF <- as.data.frame(UCBAdmissions)
xtabs(Freq ~ Gender + Admit, DF)

4. Row, column and total percentages

Convert counts to proportions with proportions() (prop.table() is the older name, kept for back-compatibility). The margin argument says which margin to divide by: 1 for rows, 2 for columns, and no margin for the grand total. For a table with named dimensions you can pass the dimension name instead, as the help page does with proportions(tbl, "Gender").

proportions(tbl, 1)   # each row sums to 1
proportions(tbl, 2)   # each column sums to 1
proportions(tbl)      # whole table sums to 1
addmargins(tbl)       # add row and column totals

The table() help page lists prop.table (now proportions), margin.table and addmargins as related functions.

5. An SPSS-style crosstab with CrossTable()

The gmodels documentation describes CrossTable() as producing “output similar to S-Plus crosstabs() and SAS Proc Freq”. It prints counts together with row, column and table proportions in one cell block, and its format argument accepts "SAS" (the default) or "SPSS".

CrossTable(mtcars$cyl, mtcars$gear, format = "SPSS")
CrossTable(mtcars$cyl, mtcars$gear, expected = TRUE, sresid = TRUE, format = "SPSS")

The arguments prop.r, prop.c and prop.t switch the row, column and total proportions on or off. expected = TRUE also sets chisq = TRUE and shows expected counts; sresid = TRUE adds standardized residuals. If you pass only one vector and no y, no tests are run.

6. A tidy data frame with janitor::tabyl()

The janitor documentation describes tabyl() as creating one-, two- or three-way frequency tables and calls it a more fully featured alternative to base R’s table(). The result is a data frame, and janitor’s adorn_ functions format it further.

tabyl(mtcars, cyl, gear)

A three-way call, tabyl(mtcars, cyl, gear, am), returns a list of tables split by the third variable.

Missing values in crosstabs

By default, table() and xtabs() do not count missing values, and CrossTable() excludes them, so a table can silently total fewer rows than your data. Each function has its own switch to change that:

  • table(): useNA = "no" never counts NA, "ifany" counts it only when it occurs, and "always" includes it even with zero counts.
  • xtabs(): addNA = TRUE gives NA its own level and counts it.
  • CrossTable(): missing.include = TRUE (default FALSE).
  • tabyl(): show_na = TRUE is the default; when NAs exist it adds a valid_percent column.

Output and how to read it

Rows are the levels of the first variable and columns the levels of the second. A count table answers “how many”; a row-proportion table answers “within each row category, what share falls in each column category”, and a column-proportion table answers the reverse. Pick the margin that matches your comparison: if the rows are your groups, compare row proportions.

To test independence, pass the table to chisq.test(). The R documentation says a matrix with at least two rows and columns is taken as a two-dimensional contingency table whose entries must be non-negative integers, and that the null hypothesis is that the joint distribution of the cell counts is the product of the row and column marginals. The returned object holds observed, expected, residuals (Pearson residuals) and stdres (standardized residuals):

res <- chisq.test(tbl)
res$expected
res$stdres

Use the standardized residuals to see which cells drive a significant result; the guide to standardized residuals explains how to read them. For effect size, follow with Cramér’s V, or the phi coefficient for a 2 by 2 table. For the wider family of R tests, see hypothesis testing with R.

Troubleshooting

  • Totals are smaller than the number of rows. Missing values are being dropped. Use the NA setting for your function from the list above.
  • xtabs() rejects my formula. Only + between variables is allowed on the right-hand side; interactions are not.
  • CrossTable() shows no test. Tests need two variables, and the test arguments (chisq, fisher, mcnemar) default to FALSE. Setting expected = TRUE turns on chisq.
  • The 2 by 2 result differs from another package. In chisq.test() the continuity correction applies only to 2 by 2 tables and is on by default (correct = TRUE). Set correct = FALSE to remove it.
  • Sparse tables. chisq.test() can compute a Monte Carlo p-value with simulate.p.value = TRUE and B replicates; the documentation notes that continuity correction is never used in that case.

Alternatives: four routes compared

Route Package Starts from Missing values Notes
table() base Vectors or factors useNA = "no" / "ifany" / "always" Names dimensions with dnn
xtabs() stats Formula on a data frame; counts on the left side if already aggregated addNA = TRUE No interactions in the formula
CrossTable() gmodels (CRAN) Two vectors missing.include = TRUE SAS or SPSS-style layout; optional chi-square, Fisher and McNemar tests
tabyl() janitor (CRAN) Data frame and column names show_na (default TRUE) One-, two- or three-way; result is a data frame

FAQ

How do I make a crosstab in R?

Use table(a, b) for two vectors or xtabs(~ a + b, data = df) for columns of a data frame. Add proportions(tbl, 1) or proportions(tbl, 2) for row or column percentages.

Can R produce an SPSS-style crosstab?

Yes. gmodels::CrossTable() accepts format = "SPSS" and can add expected counts and residuals to the cell block.

How do I build a crosstab from counts instead of raw rows?

Use xtabs(Freq ~ A + B, data), where Freq is the count column, as in the xtabs() help page example.

How do I add a chi-square test?

Pass the table to chisq.test(), or set expected = TRUE in CrossTable(). See the chi-square test guide for assumptions and reporting.

Sources (official docs)

Documentation checked on 2026-10-29.

Follow CASRAI

Research-administration guidance, standards updates and independent tool reviews.

Ask CASRAI · free to try

Ask about Crosstabs in R: table(), xtabs(), CrossTable() and tabyl()

Ask your first 2 questions free below. Subscribers get 150 a day for $29 a month.

An AI assistant specialized in research administration. It cites the sources behind every answer, labels web answers and says when it can't answer.

Answers draw on CASRAI's guides and dictionary plus the federal and funder documents we index: Federal Register, Grants.gov, Regulations.gov and UKRI.

Works on this site and inside Claude, Cursor and the AI tools you already use.

Everything CASRAI publishes — this page, the dictionary, the guides and the news — stays free to read, with no account and no card.

Referenced across the research world

University of Cambridge logoColumbia University logoCrossref logoUniversity of Edinburgh logoHarvard University logoUniversity of Oxford logoPrinceton University logoStanford School of Medicine logoUniversity College London logoORCID logoUniversity of Cambridge logoColumbia University logoCrossref logoUniversity of Edinburgh logoHarvard University logoUniversity of Oxford logoPrinceton University logoStanford School of Medicine logoUniversity College London logoORCID logo
  • University of Cambridge logo
  • Columbia University logo
  • Crossref logo
  • University of Edinburgh logo
  • Harvard University logo
  • University of Oxford logo
  • Princeton University logo
  • Stanford School of Medicine logo
  • University College London logo
  • ORCID logo

View CASRAI adoption →

Ask CASRAI · Regulatory Radar

Research-admin question? Get an answer that links its sources.

An AI assistant specialized in research administration. Every answer links its sources to check before you act. 2 questions free, no account. $29/month after.

  • Answers draw on CASRAI's guides and dictionary plus the federal and funder documents we index: Federal Register, Grants.gov, Regulations.gov and UKRI.
  • Every answer numbers its sources and links each one, so you can check the source yourself.