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.
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 dosem <-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()
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()
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()
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()
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()
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()
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()
Grouped error bars: a second variable + position_dodge()
With two grouping variables you want the bars side by side, not stacked. Summarise by bothdose 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 suppm <-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()
Error-bar width and dodge alignment
Two settings control how grouped bars look: width on geom_errorbar() sizes the cap, and the sameposition_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()
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()
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()
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()
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
NoteHow do I add error bars to a bar plot in ggplot2?
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).
NoteHow do I calculate the mean and standard deviation for error bars without dplyr?
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.
NoteWhy are my grouped error bars not lined up with the bars?
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.
NoteShould error bars show SD, SE, or a confidence interval?
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.
NoteWhat’s the difference between geom_errorbar(), geom_pointrange(), and geom_crossbar()?
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
ImportantExercise: add the error bars
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.
This lesson is reproducible: the figures are executed at build time (if they render, the code works), and the sandbox + quiz re-run live in your browser. The runtime is the judge.