Error Bars in R: A ggplot2 Guide

Add error bars to bar, line, and point plots with geom_errorbar() — live in your browser

Data Visualization

Learn to add error bars in R with ggplot2 — summarise mean ± SD with base aggregate(), then draw them with geom_errorbar(), geom_pointrange(), and friends on bar plots, line plots, and grouped plots. Edit and run the code live, no install needed.

Published

June 20, 2026

Modified

July 7, 2026

TipKey takeaways
  • Error bars show the spread or uncertainty around a summary value (a mean) — typically mean ± standard deviation (SD), standard error (SE), or a confidence interval (CI).
  • The pattern is always two steps: summarise your data (mean and spread per group), then draw with geom_errorbar() mapped to ymin/ymax.
  • Build the summary table in-block with base aggregate() — no extra packages, runs anywhere.
  • Add bars to a bar plot (geom_col), a line plot (geom_line + geom_point), or use geom_pointrange() for a clean point-with-interval.
  • For multiple groups, map the grouping variable to fill/colour and align everything with position_dodge().
  • Choose what the bar means (SD vs SE vs CI) deliberately — and label it — because they answer different questions.
  • Every plot below is rendered for you — then tweak any example live in the sandbox.
Get the book — GGPlot2 Essentials (PDF)

Introduction

Error bars turn a single summary value into an honest one: they show how much the data varies (or how uncertain the estimate is) around each mean. In R, ggplot2 draws them with one layer — geom_errorbar() — plus a few relatives (geom_pointrange(), geom_linerange(), geom_crossbar()). The work is in summarising first: every plot needs a small table of means and spreads. This lesson builds that table in-block with base R, then adds bars to bar plots, line plots, and grouped plots; the figures are rendered right here, and you can re-run or edit any of them live.

We use the built-in ToothGrowth dataset: the length of odontoblast cells (len) in guinea pigs given vitamin C at three doses (dose: 0.5, 1, or 2 mg/day) via two delivery methods (supp: orange juice OJ or ascorbic acid VC).

The summary table: mean ± SD with base aggregate()

Every error bar needs two numbers per group: a centre (the mean) and a spread (here the standard deviation). Compute both with base aggregate() so the code is self-contained — no extra packages. We merge the two results into one tidy data frame the plots can reuse.

library(ggplot2)
# summarise in-block with base R: mean + sd of len per dose
m  <- aggregate(len ~ dose, data = ToothGrowth, FUN = mean)
s  <- aggregate(len ~ dose, data = ToothGrowth, FUN = sd)
df <- data.frame(dose = factor(m$dose), len = m$len, sd = s$len)

ggplot(df, aes(x = dose, y = len)) +
  geom_point(size = 3, colour = "#3a86d4") +
  geom_errorbar(aes(ymin = len - sd, ymax = len + sd), width = 0.2, colour = "#3a86d4") +
  labs(x = "Dose (mg/day)", y = "Tooth length (mean ± SD)") +
  theme_minimal()

ggplot2 plot of mean tooth length by vitamin C dose with vertical error bars showing mean plus or minus one standard deviation.

The key mapping is aes(ymin = len - sd, ymax = len + sd)geom_errorbar() draws a vertical line from ymin to ymax with a small horizontal cap, sized by width.

Bar plot with error bars

The most common request: a bar chart of group means with error bars on top. Draw the bars with geom_col() (it uses the y value as the height), then layer the same geom_errorbar().

library(ggplot2)
m  <- aggregate(len ~ dose, data = ToothGrowth, FUN = mean)
s  <- aggregate(len ~ dose, data = ToothGrowth, FUN = sd)
df <- data.frame(dose = factor(m$dose), len = m$len, sd = s$len)

ggplot(df, aes(x = dose, y = len)) +
  geom_col(fill = "#3a86d4", alpha = 0.7) +
  geom_errorbar(aes(ymin = len - sd, ymax = len + sd), width = 0.2) +
  labs(x = "Dose (mg/day)", y = "Tooth length (mean ± SD)") +
  theme_minimal()

ggplot2 bar plot of mean tooth length by vitamin C dose, each bar topped with an error bar of plus or minus one standard deviation.

Line plot with error bars

For an ordered x-axis (a dose, a time point) a line plot reads as a trend. Add geom_line() and geom_point(), and because the x is a factor you must tell ggplot2 the points belong to one series with group = 1.

library(ggplot2)
m  <- aggregate(len ~ dose, data = ToothGrowth, FUN = mean)
s  <- aggregate(len ~ dose, data = ToothGrowth, FUN = sd)
df <- data.frame(dose = factor(m$dose), len = m$len, sd = s$len)

ggplot(df, aes(x = dose, y = len, group = 1)) +
  geom_line(colour = "#3a86d4") +
  geom_point(size = 2.5, colour = "#3a86d4") +
  geom_errorbar(aes(ymin = len - sd, ymax = len + sd), width = 0.2, colour = "#3a86d4") +
  labs(x = "Dose (mg/day)", y = "Tooth length (mean ± SD)") +
  theme_minimal()

ggplot2 line plot of mean tooth length rising with vitamin C dose, with a point and an error bar at each dose.

Upper-only error bars

Sometimes you only want to show the bar reaching up from the mean — common on bar charts to keep them from looking cluttered. Set ymin to the mean itself so the lower half disappears.

library(ggplot2)
m  <- aggregate(len ~ dose, data = ToothGrowth, FUN = mean)
s  <- aggregate(len ~ dose, data = ToothGrowth, FUN = sd)
df <- data.frame(dose = factor(m$dose), len = m$len, sd = s$len)

ggplot(df, aes(x = dose, y = len)) +
  geom_col(fill = "#3a86d4", alpha = 0.7) +
  geom_errorbar(aes(ymin = len, ymax = len + sd), width = 0.2) +
  labs(x = "Dose (mg/day)", y = "Tooth length (mean ± SD)") +
  theme_minimal()

ggplot2 bar plot of mean tooth length by dose with error bars drawn only above each bar.

geom_pointrange(): a point in the middle

geom_pointrange() draws the same interval but puts the mean point in the centre and the spread as a thin line through it — cleaner than a bar when you don’t need the cap. It reads ymin/ymax just like geom_errorbar().

library(ggplot2)
m  <- aggregate(len ~ dose, data = ToothGrowth, FUN = mean)
s  <- aggregate(len ~ dose, data = ToothGrowth, FUN = sd)
df <- data.frame(dose = factor(m$dose), len = m$len, sd = s$len)

ggplot(df, aes(x = dose, y = len)) +
  geom_pointrange(aes(ymin = len - sd, ymax = len + sd), colour = "#3a86d4") +
  labs(x = "Dose (mg/day)", y = "Tooth length (mean ± SD)") +
  theme_minimal()

ggplot2 point-range plot of mean tooth length by dose; each mean is a point with a line spanning plus or minus one standard deviation.

geom_linerange(): the interval line only

geom_linerange() draws just the vertical span — no point, no caps. Pair it with your own geom_point() when you want full control over the marker.

library(ggplot2)
m  <- aggregate(len ~ dose, data = ToothGrowth, FUN = mean)
s  <- aggregate(len ~ dose, data = ToothGrowth, FUN = sd)
df <- data.frame(dose = factor(m$dose), len = m$len, sd = s$len)

ggplot(df, aes(x = dose, y = len)) +
  geom_linerange(aes(ymin = len - sd, ymax = len + sd), colour = "#3a86d4") +
  geom_point(size = 2.5, colour = "#3a86d4") +
  labs(x = "Dose (mg/day)", y = "Tooth length (mean ± SD)") +
  theme_minimal()

ggplot2 plot of tooth length by dose showing a plain vertical interval line at each dose with a separate mean point.

geom_crossbar(): a hollow bar with a mean line

geom_crossbar() draws a hollow box spanning ymin to ymax with a horizontal line at the mean (y) — a compact way to show the interval and its centre together.

library(ggplot2)
m  <- aggregate(len ~ dose, data = ToothGrowth, FUN = mean)
s  <- aggregate(len ~ dose, data = ToothGrowth, FUN = sd)
df <- data.frame(dose = factor(m$dose), len = m$len, sd = s$len)

ggplot(df, aes(x = dose, y = len)) +
  geom_crossbar(aes(ymin = len - sd, ymax = len + sd), fill = "#3a86d4", alpha = 0.3, width = 0.5) +
  labs(x = "Dose (mg/day)", y = "Tooth length (mean ± SD)") +
  theme_minimal()

ggplot2 cross-bar plot of tooth length by dose; each group is a hollow box from minus to plus one SD with a line marking the mean.

Grouped error bars: a second variable + position_dodge()

With two grouping variables you want the bars side by side, not stacked. Summarise by both dose and supp, map supp to fill, and pass the same position_dodge() width to every layer so the bars and their error bars line up.

library(ggplot2)
# summarise by BOTH dose and supp
m  <- aggregate(len ~ dose + supp, data = ToothGrowth, FUN = mean)
s  <- aggregate(len ~ dose + supp, data = ToothGrowth, FUN = sd)
df <- merge(m, s, by = c("dose", "supp"))
names(df) <- c("dose", "supp", "len", "sd")
df$dose <- factor(df$dose)

dodge <- position_dodge(width = 0.9)
ggplot(df, aes(x = dose, y = len, fill = supp)) +
  geom_col(position = dodge) +
  geom_errorbar(aes(ymin = len - sd, ymax = len + sd),
                width = 0.2, position = dodge) +
  scale_fill_viridis_d() +
  labs(x = "Dose (mg/day)", y = "Tooth length (mean ± SD)", fill = "Supplement") +
  theme_minimal()

ggplot2 grouped bar plot of mean tooth length by dose with orange-juice and ascorbic-acid bars side by side, each with an error bar.

Error-bar width and dodge alignment

Two settings control how grouped bars look: width on geom_errorbar() sizes the cap, and the same position_dodge() width on every layer keeps each error bar centred over its bar. If the caps drift sideways, your dodge widths don’t match across layers.

library(ggplot2)
m  <- aggregate(len ~ dose + supp, data = ToothGrowth, FUN = mean)
s  <- aggregate(len ~ dose + supp, data = ToothGrowth, FUN = sd)
df <- merge(m, s, by = c("dose", "supp"))
names(df) <- c("dose", "supp", "len", "sd")
df$dose <- factor(df$dose)

ggplot(df, aes(x = dose, y = len, colour = supp)) +
  geom_pointrange(aes(ymin = len - sd, ymax = len + sd),
                  position = position_dodge(width = 0.4)) +
  scale_colour_viridis_d() +
  labs(x = "Dose (mg/day)", y = "Tooth length (mean ± SD)", colour = "Supplement") +
  theme_minimal()

ggplot2 grouped point-range plot of mean tooth length by dose and supplement, dodged so each group's interval is centred and separated.

Overlay the raw data

A mean ± SD hides the actual observations. Layer geom_jitter() from the full dataset under the summary so the reader sees both the spread of points and the summary interval — the honest version of a bar chart.

library(ggplot2)
m  <- aggregate(len ~ dose, data = ToothGrowth, FUN = mean)
s  <- aggregate(len ~ dose, data = ToothGrowth, FUN = sd)
df <- data.frame(dose = factor(m$dose), len = m$len, sd = s$len)

ggplot(mapping = aes(x = factor(dose), y = len)) +
  geom_jitter(data = ToothGrowth, width = 0.12, alpha = 0.4, colour = "grey50") +
  geom_pointrange(data = df, aes(ymin = len - sd, ymax = len + sd),
                  colour = "#3a86d4", size = 0.8) +
  labs(x = "Dose (mg/day)", y = "Tooth length") +
  theme_minimal()

ggplot2 plot of tooth length by dose with jittered raw data points behind a mean point and an error bar for each dose.

SD vs SE vs CI: choose what the bar shows

The geometry is identical — what changes is the number you put in ymin/ymax, and that changes the meaning:

  • SD (standard deviation) — how spread out the raw values are. Doesn’t shrink with more data.
  • SE (standard error = SD / √n) — how precise the mean estimate is. Shrinks as n grows.
  • CI (confidence interval, often ≈ mean ± 1.96 × SE) — a range that, by convention, captures the true mean 95% of the time.

Always label which one you used. Here we draw the standard error instead of SD:

library(ggplot2)
m <- aggregate(len ~ dose, data = ToothGrowth, FUN = mean)
s <- aggregate(len ~ dose, data = ToothGrowth, FUN = sd)
n <- aggregate(len ~ dose, data = ToothGrowth, FUN = length)
df <- data.frame(dose = factor(m$dose), len = m$len, se = s$len / sqrt(n$len))

ggplot(df, aes(x = dose, y = len)) +
  geom_col(fill = "#3a86d4", alpha = 0.7) +
  geom_errorbar(aes(ymin = len - se, ymax = len + se), width = 0.2) +
  labs(x = "Dose (mg/day)", y = "Tooth length (mean ± SE)") +
  theme_minimal()

ggplot2 bar plot of mean tooth length by dose with narrower error bars showing the standard error of the mean.

You can also let ggplot2 compute the interval on the fly with stat_summary(fun.data = mean_se, ...), but the explicit summary-then-geom_errorbar() pattern above is clearer and easier to debug.

Horizontal error bars

When your categories run down the y-axis (a flipped or naturally horizontal layout), swap to geom_errorbarh() and map xmin/xmax instead.

library(ggplot2)
m  <- aggregate(len ~ dose, data = ToothGrowth, FUN = mean)
s  <- aggregate(len ~ dose, data = ToothGrowth, FUN = sd)
df <- data.frame(dose = factor(m$dose), len = m$len, sd = s$len)

ggplot(df, aes(x = len, y = dose)) +
  geom_point(size = 3, colour = "#3a86d4") +
  geom_errorbarh(aes(xmin = len - sd, xmax = len + sd), height = 0.2, colour = "#3a86d4") +
  labs(x = "Tooth length (mean ± SD)", y = "Dose (mg/day)") +
  theme_minimal()

ggplot2 horizontal plot with dose on the y-axis and mean tooth length on the x-axis, each mean point flanked by a horizontal error bar.

Try it live

The plots above were rendered at build time. Want to experiment? Edit the code and press Run — it executes in your browser via webR (no server, no install).

Working in Python? A matplotlib / plotnine error-bars guide is coming to the Python series.

🟢 With an AI agent

Ask Prova “should these error bars show SD, SE, or a confidence interval for my data?” — it answers with code you can run, so the explanation is reproducible. The runtime is the judge. Ask Prova →

Frequently asked questions

Draw the bars with geom_col() from a summary table of group means, then layer geom_errorbar(aes(ymin = len - sd, ymax = len + sd), width = 0.2) on top. The error bar reads ymin/ymax from your computed spread (e.g. mean ± SD).

Use base aggregate() twice — once with FUN = mean and once with FUN = sd — then combine the two results into one data frame. For example aggregate(len ~ dose, data = ToothGrowth, FUN = mean) gives the per-group means, no extra packages needed.

Your layers are using different dodge settings. Define one position_dodge(width = 0.9) and pass the same object to every layer (geom_col(position = dodge) and geom_errorbar(..., position = dodge)) so each error bar stays centred over its bar.

They answer different questions: SD shows how spread the raw values are, SE (= sd / sqrt(n)) shows how precise the mean estimate is, and a CI (≈ mean ± 1.96 × SE) is a range that conventionally captures the true mean 95% of the time. The geometry is identical — only the number in ymin/ymax changes — so always label which one you used.

All three read ymin/ymax, but draw differently: geom_errorbar() makes a capped vertical line, geom_pointrange() puts the mean point in the centre of a thin interval line, and geom_crossbar() draws a hollow box with a horizontal line at the mean (y). Use geom_linerange() when you want just the interval span with no point or caps.

Test your understanding

The summary table is built for you. Complete the code so each bar gets an error bar of mean ± SD.

# Use geom_errorbar() and map ymin = len - sd, ymax = len + sd inside aes()
library(ggplot2) m <- aggregate(len ~ dose, data = ToothGrowth, FUN = mean) s <- aggregate(len ~ dose, data = ToothGrowth, FUN = sd) df <- data.frame(dose = factor(m$dose), len = m$len, sd = s$len) ggplot(df, aes(x = dose, y = len)) + geom_col(fill = "#3a86d4", alpha = 0.7) + geom_errorbar(aes(ymin = len - sd, ymax = len + sd), width = 0.2)
library(ggplot2)
m  <- aggregate(len ~ dose, data = ToothGrowth, FUN = mean)
s  <- aggregate(len ~ dose, data = ToothGrowth, FUN = sd)
df <- data.frame(dose = factor(m$dose), len = m$len, sd = s$len)

ggplot(df, aes(x = dose, y = len)) +
  geom_col(fill = "#3a86d4", alpha = 0.7) +
  geom_errorbar(aes(ymin = len - sd, ymax = len + sd), width = 0.2)

Conclusion

You summarised ToothGrowth with base aggregate(), then added error bars to a point plot, a bar plot, and a line plot with geom_errorbar(); tried geom_pointrange(), geom_linerange(), and geom_crossbar(); dodged grouped bars with a second variable; overlaid the raw data; and chose deliberately between SD, SE, and CI. Next, explore the box plot and violin plot — which show a full distribution rather than a single summary — and the rest of the ggplot2 grammar.

Reuse

Citation

BibTeX citation:
@online{2026,
  author = {},
  title = {Error {Bars} in {R:} {A} Ggplot2 {Guide}},
  date = {2026-06-20},
  url = {https://www.datanovia.com/learn/data-visualization/ggplot2/error-bars},
  langid = {en}
}
For attribution, please cite this work as:
“Error Bars in R: A Ggplot2 Guide.” 2026. June 20. https://www.datanovia.com/learn/data-visualization/ggplot2/error-bars.