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

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] 3

2️⃣ 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.7

3️⃣ 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   6

4️⃣ 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     3

Your 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.67

Mini-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 here

Common Errors (and the fix)

Pro Tips

📋 Quick Reference — Frequency Tables

TaskR Syntax
Count categoriestable(x)
Cross-tabulatetable(x, y)
Proportionsprop.table(table(x))
Row proportionsprop.table(tab, margin = 1)
Add totalsaddmargins(tab)
Bin numbers firsttable(cut(x, breaks))

🎉 Lesson Complete!

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

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.