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 = TRUEgives NA its own level and counts it.CrossTable():missing.include = TRUE(defaultFALSE).tabyl():show_na = TRUEis the default; when NAs exist it adds avalid_percentcolumn.
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 toFALSE. Settingexpected = TRUEturns onchisq.- 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). Setcorrect = FALSEto remove it. - Sparse tables.
chisq.test()can compute a Monte Carlo p-value withsimulate.p.value = TRUEandBreplicates; 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.
- R documentation, table {base}
- R documentation, xtabs {stats}
- R documentation, proportions {base}
- R documentation, chisq.test {stats}
- gmodels 2.19.1, CrossTable; package on CRAN
- janitor, tabyl reference








