Frequency Tables
Reviewed & published by Brayan K
A frequency table counts how many times each distinct value appears in your data, and in R you build them with table() and convert them to proportions with prop.table().
Part of the free R course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
You'll learn one-way and two-way (cross) tabulation, turning counts into proportions and percentages, adding row and column totals with addmargins(), and binning continuous data with cut() before you count it.
What You'll Learn in This Lesson
- Counting categories with one-way table()
- Cross-tabulating two variables with table(x, y)
- Turning counts into proportions with prop.table()
- Row vs column proportions using margin = 1 / 2
- Adding totals with addmargins()
- Binning numbers with cut() before tabulating
1️⃣ One-Way Tables with table()
table() takes a vector and counts each unique value, returning a named table sorted alphabetically. It's the fastest way to see the distribution of a categorical variable.
# table() counts how many times each unique value appears.
pets <- c("cat", "dog", "cat", "fish", "dog", "cat")
table(pets)
# pets
# cat dog fish
# 3 2 1
# It sorts the categories alphabetically and labels each count.
# length(table(pets)) tells you how many distinct categories there are:
length(table(pets)) # -> [1] 32️⃣ Proportions with prop.table()
Counts answer "how many?"; proportions answer "what share?". Wrap a count table in prop.table() to get fractions that sum to 1, then multiply by 100 and round for friendly percentages.
# prop.table() turns counts into PROPORTIONS (they sum to 1).
pets <- c("cat", "dog", "cat", "fish", "dog", "cat")
counts <- table(pets)
prop.table(counts)
# pets
# cat dog fish
# 0.5000000 0.3333333 0.1666667
# Multiply by 100 for percentages, round for readability:
round(prop.table(counts) * 100, 1)
# pets
# cat dog fish
# 50.0 33.3 16.73️⃣ Two-Way Tables & Margins
Pass two vectors to table() to cross-tabulate them — the first becomes rows, the second columns. addmargins() appends row and column totals (labelled Sum), and margin.table() collapses to one variable's totals.
# A two-way table cross-tabulates TWO variables.
sex <- c("F", "M", "F", "M", "F", "M")
likes <- c("yes", "no", "yes", "yes", "no", "no")
tab <- table(sex, likes)
tab
# likes
# sex no yes
# F 1 2
# M 2 1
# addmargins() appends row/column totals (Sum):
addmargins(tab)
# likes
# sex no yes Sum
# F 1 2 3
# M 2 1 3
# Sum 3 3 64️⃣ Binning with cut()
Continuous numbers (ages, prices) have too many unique values to tabulate directly. cut() slices them into labelled intervals — a factor — which you then feed to table(). You choose the boundaries with breaks and the names with labels.
# cut() bins a numeric vector into categories BEFORE you tabulate.
ages <- c(5, 17, 23, 45, 67, 31, 8, 52)
groups <- cut(ages,
breaks = c(0, 18, 40, 100),
labels = c("child", "young", "older"))
table(groups)
# groups
# child young older
# 3 2 3Your turn. Fill in the ___ blanks, run it, and compare with the expected output.
# 🎯 YOUR TURN — replace each ___ then run it.
votes <- c("yes", "no", "yes", "yes", "no", "yes")
# 1) Count each response with table()
counts <- ___(votes) # 👉 replace ___ with table
print(counts)
# 2) Show the proportions, rounded to 2 decimals
print(round(prop.table(counts), 2))
# ✅ Expected output:
# votes
# no yes
# 2 4
# votes
# no yes
# 0.33 0.67Mini-Challenge: Survey Dashboard
Build the full picture from one vector pair: counts, a cross-tab, row proportions, and totals. This is exactly the workflow for summarising a categorical survey.
# 🎯 MINI-CHALLENGE: survey results dashboard.
#
# region <- c("N","S","N","E","S","N","E","S")
# buy <- c("yes","no","yes","yes","no","yes","no","yes")
#
# 1. One-way table of region counts with table(region).
# 2. Two-way table of region vs buy with table(region, buy).
# 3. Row proportions: prop.table(table(region, buy), margin = 1).
# 4. Add totals with addmargins() to the two-way table.
#
# ✅ Example output (counts only shown):
# region
# E N S
# 2 3 3
# your code hereCommon Errors (and the fix)
- ❌ Expecting table() to count NA. ✅ NAs are dropped by default — add useNA = "ifany" to include them.
- ❌ Forgetting the margin: prop.table(tab) divides by the grand total, not the row. ✅ Pass margin = 1 for rows.
- ❌ Tabulating raw numbers and getting one count per value. ✅ Bin first with cut().
- ❌ cut() giving NA for values outside the breaks. ✅ Make sure your breaks span the full range.
Pro Tips
- 💡 Plot it instantly: barplot(table(x)) turns a frequency table into a bar chart.
- 💡 Sort by frequency: sort(table(x), decreasing = TRUE) ranks categories most-common first.
- 💡 Factor order: set a factor's levels first to control the column order of your table.
📋 Quick Reference — Frequency Tables
| Task | R Syntax |
|---|---|
| Count categories | table(x) |
| Cross-tabulate | table(x, y) |
| Proportions | prop.table(table(x)) |
| Row proportions | prop.table(tab, margin = 1) |
| Add totals | addmargins(tab) |
| Bin numbers first | table(cut(x, breaks)) |
🎉 Lesson Complete!
- ✅ table(x) counts categories; table(x, y) cross-tabulates
- ✅ prop.table() turns counts into proportions; margin = 1/2 chooses rows/columns
- ✅ addmargins() appends row and column totals
- ✅ cut() bins continuous data into a factor you can tabulate
Practice quiz
What does table(x) do for a vector x?
- Sorts the values
- Removes duplicates
- Counts how many times each unique value appears
- Returns the mean
Answer: Counts how many times each unique value appears. table() counts the frequency of each distinct value.
How does table() order its category labels by default?
- Alphabetically (or by factor level)
- By descending count
- By first appearance
- Randomly
Answer: Alphabetically (or by factor level). table() sorts categories alphabetically, or by factor level order if set.
What does prop.table(table(x)) return?
- Counts again
- Proportions that sum to 1
- Percentages out of 100
- Raw frequencies
Answer: Proportions that sum to 1. prop.table() converts counts into proportions that sum to 1.
To get percentages from a count table, you would...
- Use table(x, 100)
- Use sum(table(x))
- Divide by length only
- Multiply prop.table(table(x)) by 100
Answer: Multiply prop.table(table(x)) by 100. prop.table() gives proportions; multiply by 100 for percentages.
In prop.table(tab, margin = 1), what sums to 1?
- The grand total
- Each column
- Each row
- Nothing
Answer: Each row. margin = 1 operates over rows, so each row's proportions sum to 1.
What does prop.table(tab, margin = 2) make sum to 1?
- Each row
- Each column
- The whole table
- The diagonal
Answer: Each column. margin = 2 operates over columns, so each column sums to 1.
What does addmargins(tab) do to a two-way table?
- Appends row and column totals
- Removes empty cells
- Converts to proportions
- Sorts by frequency
Answer: Appends row and column totals. addmargins() adds row/column totals labelled Sum.
Why bin numeric data with cut() before table()?
- table() cannot accept numbers
- cut() sorts the data
- It removes outliers
- Raw numbers have too many unique values to tabulate usefully
Answer: Raw numbers have too many unique values to tabulate usefully. cut() groups continuous values into a few labelled intervals (a factor).
By default, how does table() treat NA values?
- Counts them as a category
- Drops them unless useNA is set
- Errors
- Converts them to 0
Answer: Drops them unless useNA is set. NAs are dropped by default; add useNA = "ifany" to include them.
What does table(x, y) produce?
- A two-way cross-tabulation of x and y
- The sum of x and y
- A merged vector
- An error
Answer: A two-way cross-tabulation of x and y. Passing two vectors cross-tabulates them: first is rows, second is columns.
Continue this course
- Previous: Regular Expressions (grepl, gsub)
- Next: Error Handling (tryCatch) — Catch errors and warnings safely with tryCatch, try, and stopifnot
- Quick reference: R cheat sheet
Frequently asked questions
What does table() actually return?
table() returns a special object of class 'table' — essentially a named integer vector (for one variable) or a matrix-like array (for two or more) where the names are the category labels and the values are the counts. Because it is array-like, you can index it, pass it to prop.table() and addmargins(), and feed it straight into barplot(). If you want it as an ordinary data frame for further work, wrap it: as.data.frame(table(x)).
How do I turn counts into percentages?
Use prop.table() on the count table to get proportions (values between 0 and 1 that sum to 1), then multiply by 100. So round(prop.table(table(x)) * 100, 1) gives nicely rounded percentages. For a two-way table you usually want proportions within each row or column rather than over the whole table — pass margin = 1 for row proportions or margin = 2 for column proportions to prop.table().
What is the difference between margin = 1 and margin = 2?
In two-way functions like prop.table() and addmargins(), margin = 1 operates over ROWS (so row proportions sum to 1, answering 'within each row category, what share fell into each column?') and margin = 2 operates over COLUMNS. With no margin argument, prop.table() divides by the grand total so the whole table sums to 1. A handy mnemonic: 1 is the first dimension (rows), 2 is the second dimension (columns).
Why would I use cut() before table()?
table() counts discrete categories, but raw numeric data like ages or incomes has too many unique values to tabulate usefully. cut() solves this by binning the numbers into a smaller set of labelled intervals — for example ages into 'child', 'young', 'older' — turning a continuous variable into a factor you can then count with table(). You control the cut points with breaks and the names with labels, giving you a clean frequency distribution.