Plotting categorical data

Bar charts done right: counts, order, proportions, labels — and what to avoid

Visualise categorical variables with ggplot2: geom_bar() vs geom_col(), ordering bars, stacked, dodged and proportional bars for two variables, labels, horizontal bars, why pie charts are usually worse, and why bar charts of means with error bars hide the data.

Level 2 · Describe & explore ⏱ ~60 min Prerequisites: The grammar of graphics, Describing two variables

NoteLearning objectives

By the end of this lesson you will be able to:

  • choose between geom_bar() (R counts for you) and geom_col() (you supply the heights);
  • order bars meaningfully with forcats, and draw readable horizontal bars;
  • show two categorical variables with stacked, dodged and proportional bars, and pick the right one for the question;
  • add labels to bars;
  • explain why pie charts and bar charts of means (“dynamite plots”) are usually poor choices, and what to use instead.
library(ggplot2)
library(dplyr)
library(forcats)

1. Why bar charts?

A bar chart is the picture of a frequency table (see Frequency tables): one bar per category, with a length proportional to a number — usually a count or a proportion. It works so well because we compare lengths from a common baseline very accurately.

Two different situations need two different geoms:

You have… Use What ggplot2 does
One row per observation (e.g., one row per car) geom_bar() counts the rows in each category (stat = "count")
One row per category, with the height already computed (a count, a mean, a percentage) geom_col() draws the values as they are (stat = "identity")

2. Counting with geom_bar()

How many cars in mtcars have 4, 6 or 8 cylinders?

ggplot(mtcars, aes(x = factor(cyl))) +
  geom_bar() +
  labs(x = "Cylinders", y = "Number of cars")
Figure 1: Number of cars by number of cylinders.

factor(cyl) treats cylinders as categories. We never computed the counts: geom_bar() did it.

Setting (not mapping) fill and alpha changes the look of all bars; mapping fill to the same variable as x colours each bar differently but adds no information, so we remove the legend:

ggplot(mtcars, aes(factor(cyl))) +
  geom_bar(fill = "#0072B2", alpha = 0.6) +
  labs(x = "Cylinders", y = "Number of cars")

ggplot(mtcars, aes(factor(cyl), fill = factor(cyl))) +
  geom_bar() +
  guides(fill = "none") +
  labs(x = "Cylinders", y = "Number of cars")
(a) fill and alpha set
(b) fill mapped, legend removed
Figure 2: Left: a constant fill colour with transparency. Right: fill mapped to the x variable — colourful, but redundant.

3. Precomputed values with geom_col()

Often you first summarise with dplyr and then plot the result. The same counts as above, computed explicitly:

cyl_counts <- mtcars |>
  count(cyl) |>
  mutate(cyl = factor(cyl))
cyl_counts
#>   cyl  n
#> 1   4 11
#> 2   6  7
#> 3   8 14
ggplot(cyl_counts, aes(x = cyl, y = n)) +
  geom_col() +
  labs(x = "Cylinders", y = "Number of cars")
Figure 3: The same chart from a summary table, with geom_col().

Using geom_bar() on cyl_counts would be a mistake: it would count the rows of the summary table (one per category) and draw three bars of height 1.

4. Order matters

For a nominal variable the alphabetical order of the bars is arbitrary and makes comparisons harder. Sort the bars by frequency with forcats::fct_infreq():

states <- tibble(division = state.division)

ggplot(states, aes(y = fct_rev(fct_infreq(division)))) +
  geom_bar() +
  labs(x = "Number of states", y = NULL)
Figure 4: Number of US states in each census division, ordered by frequency, with horizontal bars for readable labels.

Three things happen here:

  1. fct_infreq() orders the levels from most to least frequent;
  2. fct_rev() reverses that order, because ggplot2 draws the first level at the bottom of a vertical axis — so the largest bar ends up on top;
  3. mapping the variable to y gives horizontal bars, which leave plenty of room for long category names. (The older way is to map x and add coord_flip(); both work.)

When the heights are precomputed, use fct_reorder() to order a factor by another variable:

feed_means <- chickwts |>
  summarise(mean_weight = mean(weight), n = n(), .by = feed)

ggplot(feed_means, aes(x = mean_weight, y = fct_reorder(feed, mean_weight))) +
  geom_col() +
  labs(x = "Mean weight after 6 weeks (g)", y = "Feed")
Figure 5: Mean chick weight by feed type, ordered by the mean.

Do not reorder ordinal variables (education level, agreement scales, age classes, months): their natural order carries information. Keep the level order and, if needed, set it with factor(..., levels = ...).

5. Adding labels

Labels with the exact values help when readers need the numbers. On a summary table, map label and nudge the text above the bars:

ggplot(cyl_counts, aes(cyl, n)) +
  geom_col(width = 0.6) +
  geom_text(aes(label = n), vjust = -0.4) +
  expand_limits(y = max(cyl_counts$n) * 1.1) +
  labs(x = "Cylinders", y = "Number of cars")
Figure 6: Counts written above the bars.

vjust = -0.4 moves the text just above the top of each bar, and expand_limits() leaves room so that the highest label is not cut off. With geom_bar() the counts exist only inside the plot; refer to them with after_stat():

ggplot(penguins, aes(island)) +
  geom_bar(width = 0.6) +
  geom_text(stat = "count", aes(label = after_stat(count)), vjust = -0.4) +
  labs(x = NULL, y = "Number of penguins")
Figure 7: Labels on a geom_bar() chart via after_stat(count).

For horizontal bars, use hjust instead of vjust:

mpg_by_cyl <- mtcars |>
  summarise(mean_mpg = mean(mpg), .by = cyl) |>
  mutate(cyl = factor(cyl))

ggplot(mpg_by_cyl, aes(x = mean_mpg, y = cyl)) +
  geom_col(width = 0.6) +
  geom_text(aes(label = round(mean_mpg, 1)), hjust = -0.15) +
  expand_limits(x = max(mpg_by_cyl$mean_mpg) * 1.1) +
  labs(x = "Mean miles per gallon", y = "Cylinders")
Figure 8: Mean fuel economy by cylinders, horizontal bars with rounded labels.

(We will come back to bars of means in Section 8 — they have a serious problem.)

6. Two categorical variables

How is the transmission type (am: automatic or manual) distributed across cylinder classes? First we turn the codes into labelled factors:

cars <- mtcars |>
  mutate(cyl = factor(cyl),
         am = factor(am, levels = c(0, 1), labels = c("automatic", "manual")))

The position argument decides how the bars of the second variable are arranged:

p <- ggplot(cars, aes(x = cyl, fill = am)) +
  labs(x = "Cylinders", fill = "Transmission")

p + geom_bar() + labs(y = "Number of cars")
p + geom_bar(position = position_dodge(preserve = "single")) +
  labs(y = "Number of cars")
p + geom_bar(position = "fill") + labs(y = "Proportion")
(a) Stacked (default)
(b) Dodged
(c) Filled (proportions)
Figure 9: Three arrangements of the same counts.
Arrangement Answers best Weakness
Stacked Totals per category, plus a rough split Only the bottom segment shares a baseline; the others are hard to compare
Dodged Compare counts of the subgroups directly Totals are harder to see
Filled (position = "fill") Compare conditional proportions across categories Hides how many observations are behind each bar

The filled chart is the picture of the conditional distribution from Describing two variables: within each cylinder class, the proportion of automatic and manual cars. It shows clearly that 86% of 8-cylinder cars are automatic. Put the explanatory variable on the x axis and the response in the fill — just as you would compute percentages within the categories of the explanatory variable.

position_dodge(preserve = "single") keeps all bars the same width even when a combination is missing — important for data such as island × species in penguins, which has empty cells.

Proportional bars with percentage labels

To label a filled chart, compute the proportions yourself and use position_fill(vjust = 0.5) to centre the labels inside the segments:

island_species <- penguins |>
  count(island, species) |>
  mutate(prop = n / sum(n), .by = island)

island_n <- count(penguins, island)

ggplot(island_species, aes(x = island, y = prop, fill = species)) +
  geom_col(position = "fill") +
  geom_text(aes(label = scales::percent(prop, accuracy = 1)),
            position = position_fill(vjust = 0.5), colour = "white") +
  geom_text(data = island_n, aes(x = island, y = 1.04,
                                 label = paste0("n = ", n)),
            inherit.aes = FALSE) +
  labs(x = NULL, y = "Proportion", fill = "Species")
Figure 10: Species composition of each island, with percentages and group sizes.

Writing \(n\) above each bar fixes the main weakness of proportional bars: Torgersen’s 100% Adelie is based on 52 birds, Biscoe’s figures on 168.

Mosaic plots

A mosaic plot goes one step further: bar widths are proportional to the size of each group, so the area of each rectangle is proportional to the count in that cell. Base R draws one directly from a table:

mosaicplot(table(penguins$island, penguins$species),
           main = "", xlab = "Island", ylab = "Species",
           color = c("#E69F00", "#56B4E9", "#009E73"))
Figure 11: Mosaic plot of island by species: column widths reflect the number of birds per island.

(For a ggplot2 version, see the ggmosaic package.)

7. Why pie charts are usually worse

A pie chart shows proportions as angles and areas of slices. Humans judge angles and areas much less accurately than lengths on a common scale. Compare the same six counts:

ggplot(chickwts, aes(x = "", fill = feed)) +
  geom_bar(width = 1) +
  coord_polar(theta = "y") +
  theme_void() +
  labs(fill = "Feed")

ggplot(chickwts, aes(y = fct_rev(fct_infreq(feed)))) +
  geom_bar() +
  labs(x = "Number of chicks", y = NULL)
(a) Pie chart
(b) Bar chart
Figure 12: The number of chicks per feed type as a pie chart and as a bar chart. Which feed has the most chicks?

In the pie it is almost impossible to say whether soybean or casein is larger, or which slices are equal; in the bar chart it is immediate (14 soybean chicks, three feeds with 12 each). Pies also need a colour legend, and they fail completely for comparing two groups side by side. A pie can be acceptable for two or three parts of a whole with very different sizes; otherwise use bars. Never use 3-D pies: perspective distorts the slices.

The pie is built with the grammar of graphics too: a single stacked bar (x = "") drawn in polar coordinates (coord_polar(theta = "y")).

8. Bars for means: the “dynamite plot” problem

A very common chart in scientific papers shows the mean of each group as a bar with an error bar on top, usually the mean ± one standard error (SE). The SE measures how precisely a sample mean estimates the population mean: \(\text{SE} = s / \sqrt{n}\) (you will learn exactly why in Sampling distributions).

feed_summary <- chickwts |>
  summarise(n = n(),
            mean = mean(weight),
            se = sd(weight) / sqrt(n),
            .by = feed)
feed_summary
#>        feed  n  mean    se
#> 1 horsebean 10 160.2 12.21
#> 2   linseed 12 218.8 15.08
#> 3   soybean 14 246.4 14.47
#> 4 sunflower 12 328.9 14.10
#> 5  meatmeal 11 276.9 19.57
#> 6    casein 12 323.6 18.60
ggplot(feed_summary, aes(feed, mean)) +
  geom_col(fill = "grey70") +
  geom_errorbar(aes(ymin = mean - se, ymax = mean + se), width = 0.3) +
  labs(x = NULL, y = "Weight (g)") +
  theme(axis.text.x = element_text(angle = 45, hjust = 1))

ggplot(chickwts, aes(feed, weight)) +
  geom_jitter(width = 0.15, height = 0, alpha = 0.4) +
  stat_summary(fun.data = mean_se, geom = "pointrange",
               colour = "#D55E00") +
  labs(x = NULL, y = "Weight (g)") +
  theme(axis.text.x = element_text(angle = 45, hjust = 1))
(a) Dynamite plot
(b) Data, mean and ± 1 SE
Figure 13: Left: the classic ‘dynamite plot’ (bar = mean, whisker = ± 1 SE). Right: the same means and SEs drawn over the individual chicks.

What is wrong with the dynamite plot?

  1. It hides the data. With 10–14 chicks per group, the full data fit on the plot. The bars say nothing about spread, skewness, outliers or \(n\); very different datasets give identical dynamite plots.
  2. The bar suggests the data go from 0 to the mean. For counts a bar from zero is natural; for a mean it implies that all values are inside the bar, while in reality about half of them are above it.
  3. The error bar is ambiguous. Readers rarely know whether it is ± 1 SD, ± 1 SE or a 95% confidence interval — three very different things. Always state it in the caption.
  4. ± 1 SE is not a range for the data. It describes the uncertainty of the mean, not where individual observations lie (most chicks are far outside the error bars on the right).

Better alternatives: points with jitter plus mean ± SE (right panel), boxplots with points, or violins (see Plotting distributions). mean_se() is a ggplot2 helper that returns the mean and mean ± 1 SE for stat_summary().

For a group with \(n\) observations, mean \(\bar x\) and standard deviation \(s\):

  • \(\bar x \pm s\) describes the spread of the observations;
  • \(\bar x \pm s/\sqrt n\) (± 1 SE) describes the uncertainty of the mean; it shrinks as \(n\) grows;
  • \(\bar x \pm t_{0.975,\,n-1} \cdot s/\sqrt n\) is a 95% confidence interval for the population mean (about \(\bar x \pm 2\,\text{SE}\) for moderate \(n\)); see Confidence intervals.

In stat_summary(), fun.data = mean_cl_normal (which needs the Hmisc package) gives t-based 95% intervals, and mean_se(mult = 2) gives mean ± 2 SE.

WarningCommon pitfalls
  1. geom_bar() on a summary table. It counts rows (all bars of height 1). Use geom_col() when you already have the heights.
  2. Alphabetical order for nominal categories. Order by frequency (fct_infreq()) or by value (fct_reorder()); keep the natural order for ordinal variables.
  3. Bars that do not start at zero. A bar’s length is the value; cutting the axis exaggerates differences. (If zero is irrelevant, use points instead of bars.)
  4. Stacked bars for comparing subgroups. Only the bottom segment has a common baseline; use dodged or filled bars.
  5. Proportional bars without \(n\). 100% of 3 is not the same as 100% of 300.
  6. Dynamite plots. Bars of means with error bars hide the data; show the points, and say what the error bars are.

9. Check your understanding

geom_col() with aes(x = country, y = population) — the heights are already in the data. geom_bar() would count rows (one per country) and draw bars of height 1.

Filled bars (position = "fill") with age group on the x axis and smoking status as fill: they show the conditional proportions directly. Keep the age groups in their natural order and write \(n\) on each bar.

It shows the distribution and the number of observations (spread, skewness, outliers are visible), and it does not suggest that the values lie between zero and the mean. (It also makes clear that ± SE describes the mean, not the data.)

10. Exercises

Interactive (run in your browser)

★ Exercise 1 — ordered horizontal bars. Draw a horizontal bar chart of the number of chicks per feed in chickwts, with the most frequent feed on top.

fct_infreq() sorts by frequency; fct_rev() reverses the order so that the largest bar is at the top.

ggplot(chickwts, aes(y = fct_rev(fct_infreq(feed)))) +
  geom_bar() +
  labs(x = "Number of chicks", y = NULL)

Soybean has the most chicks (14); horsebean the fewest (10).

★★ Exercise 2 — debug. This code should draw the mean len per supplement type in ToothGrowth, but all bars have height 1. Fix it.

The heights are already computed in mean_len. Which geom uses precomputed heights, and what must be mapped to y?

ggplot(tg_means, aes(x = supp, y = mean_len)) +
  geom_col() +
  labs(x = "Supplement", y = "Mean tooth length")

geom_bar() counted the rows of tg_means (one per supplement). With geom_col() and y = mean_len the bars show the means (about 20.7 for orange juice, OJ, and 17.0 for ascorbic acid, VC). Even better: show the 30 observations per group with points, as in Section 8.

★★ Exercise 3 — conditional proportions. Using penguins, draw filled bars showing the proportion of females and males within each species (exclude birds with unknown sex). Is the sex ratio balanced?

Filter with !is.na(sex) and use position = "fill".

penguins |>
  filter(!is.na(sex)) |>
  ggplot(aes(x = species, fill = sex)) +
  geom_bar(position = "fill") +
  labs(x = NULL, y = "Proportion", fill = "Sex")

In every species the split is very close to 50/50 — the researchers sampled pairs of birds.

★★★ Exercise 4 — replace a dynamite plot. For PlantGrowth (plant weight by group), draw jittered points with the group mean ± 1 SE on top. Then answer: do the error bars show where most plants are?

The ggplot2 helper that returns the mean and mean ± 1 SE is mean_se.

ggplot(PlantGrowth, aes(group, weight)) +
  geom_jitter(width = 0.1, height = 0, alpha = 0.5) +
  stat_summary(fun.data = mean_se, geom = "pointrange", colour = "#D55E00") +
  labs(x = NULL, y = "Dried weight (g)",
       caption = "Orange: mean ± 1 standard error")

No: many plants lie well outside the error bars. The SE describes the precision of each mean (with 10 plants per group), not the spread of the plants. The caption saying what the error bars are is essential.

Practice on your own computer

★★ Exercise 5 — hair and eye colour. Convert HairEyeColor to a data frame with as.data.frame() (columns Hair, Eye, Sex, Freq). Draw (a) dodged bars of the counts of eye colour for each hair colour, (b) filled bars of the proportions, with percentage labels. Use geom_col() because the counts are in Freq. Which chart shows best that blond people mostly have blue eyes?

hec <- as.data.frame(HairEyeColor) |>
  summarise(n = sum(Freq), .by = c(Hair, Eye)) |>
  mutate(prop = n / sum(n), .by = Hair)

ggplot(hec, aes(Hair, n, fill = Eye)) +
  geom_col(position = "dodge") +
  labs(y = "Number of students")

ggplot(hec, aes(Hair, prop, fill = Eye)) +
  geom_col(position = "fill") +
  geom_text(aes(label = scales::percent(prop, accuracy = 1)),
            position = position_fill(vjust = 0.5), size = 3) +
  labs(y = "Proportion within hair colour")

The filled chart shows the conditional distribution directly: about 74% of blond students have blue eyes. The fill colours here are the course’s Okabe–Ito palette; for eye colours you might prefer scale_fill_manual() with intuitive colours (keeping them distinguishable).

Summary

Goal ggplot2
Counts from raw data geom_bar()
Precomputed heights geom_col()
Order by frequency / by value fct_infreq(x), fct_reorder(x, value); fct_rev() to reverse
Horizontal bars map the category to y (or coord_flip())
Stacked / dodged / filled position = "stack" / position_dodge(preserve = "single") / "fill"
Value labels geom_text(aes(label = n), vjust = -0.4); with geom_bar(): stat = "count", aes(label = after_stat(count))
Labels in filled bars geom_text(..., position = position_fill(vjust = 0.5))
Mosaic plot mosaicplot(table(a, b))
Mean ± SE over data geom_jitter() + stat_summary(fun.data = mean_se, geom = "pointrange")
  • Bars compare lengths from zero: they must start at zero.
  • Order nominal bars by size; keep ordinal categories in their natural order.
  • For two variables, filled bars show conditional proportions — add \(n\).
  • Prefer bars to pie charts.
  • Do not hide data behind bars of means: show the observations and say what error bars represent.
  • Wilke, C. O., Fundamentals of Data Visualization, chapters “Visualizing amounts” and “Visualizing proportions”, https://clauswilke.com/dataviz/
  • Wickham, Çetinkaya-Rundel & Grolemund, R for Data Science (2e), chapter “Factors”, https://r4ds.hadley.nz
  • Weissgerber, T. L. et al. (2015). Beyond bar and line graphs: time for a new data presentation paradigm. PLOS Biology, 13(4), e1002128.
Back to top