Skip to main content
v2026.11,772 entries · CC-BY 4.0

Box Plots in R: boxplot() and ggplot2’s geom_boxplot()

A runnable R workflow for box plots: base R’s boxplot() and its fivenum()-based hinges, ggplot2’s geom_boxplot() and its quantile()-based hinges (and why the two can draw different box edges from identical data), outlier flagging via the 1.5×IQR fence, notches, and overlaying raw data points with geom_jitter().

Written and maintained by CASRAI Editorial Board

Last updated

Base R’s boxplot(x) draws a box-and-whisker summary straight from a numeric vector or a y ~ group formula, with no setup; ggplot2::geom_boxplot() is the layered-grammar alternative, needed the moment you want to facet by group, overlay the raw data points, or match a house plotting style. Both are covered here as one page because they answer the same practical question — “what are the five numbers behind this box, and which points count as outliers” — and, less obviously, because they don’t always draw the same box from the same data.

For the statistical logic of the median, quartiles and the interquartile range that a box plot draws, see CASRAI’s guide to the interquartile range and five-number summary first and come back here to build one in R specifically. Building the same chart in another package? See SPSS or Stata. For the companion chart type built the same two ways in R, see histograms in R.

Code below targets R 4.6.1 with ggplot2 4.0.3 — the same versions used throughout CASRAI’s R coverage. The worked numbers in the sections below use two small datasets chosen specifically because they’re small enough to check by hand: an eight-value arithmetic sequence, and a twelve-value set of exam scores with one clear outlier. Every quartile, hinge, fence and whisker value shown was computed directly from R’s own documented algorithms — fivenum() for the hinges boxplot() uses, and quantile()‘s default (type 7) method for the ordinary quartiles geom_boxplot() uses — and cross-checked arithmetically, not read off a rendered plot.

boxplot(): the one-line base R box plot

boxplot() needs only a numeric vector, or a formula splitting a numeric variable by a grouping factor. Like hist(), it plots immediately and also returns an object — assign it (or call boxplot.stats() directly, which skips the plot) to inspect the five numbers it actually drew instead of reading them off the chart.

x <- c(1, 3, 5, 7, 9, 11, 13, 15)
boxplot.stats(x)$stats
#> [1]  1  4  8 12 15

That vector is, in order: the lower whisker end, the lower hinge, the median, the upper hinge, and the upper whisker end. With no outliers in this data, the whiskers reach all the way to the min and max — 1 and 15.

boxplot() and geom_boxplot() can draw different box edges from identical data

This is the single most useful thing to know before trusting a box plot’s exact proportions, and it’s easy to miss because both functions call their box edges “hinges” or “quartiles” interchangeably in casual use. They aren’t computed the same way:

  • boxplot() gets its box edges from fivenum(), R’s Tukey-style “hinge” algorithm — a specific averaging rule based on the sample size, not a percentile interpolation.
  • geom_boxplot() gets its box edges from stats::quantile() at the default type-7 method — the ordinary linear-interpolation quartiles most statistics software reports as Q1/Q3.

The two agree exactly for some sample sizes and genuinely diverge for others. On the same eight-value sequence above:

quantile(x)
#>    0%   25%   50%   75%  100%
#>     1   4.5     8  11.5    15 

Lower hinge 4 vs. Q1 4.5. Upper hinge 12 vs. Q3 11.5. Same eight numbers, two defensible but different box widths — ggplot2‘s own ?stat_boxplot documentation states plainly that its hinges differ slightly from boxplot()‘s and points a reader to boxplot.stats() for the base R method. A figure copied from a paper’s base-R box plot into a ggplot2-based reproduction (or vice versa) can end up with visibly different box edges on the exact same underlying data, through no error on either side — worth knowing before assuming a discrepancy between two plots of the “same” data means a data problem.

Outlier flagging: the 1.5×IQR fence, and what changes when a point is flagged

Both functions apply the same rule for the whisker reach, by default: extend to the most extreme data point still within 1.5 × the box’s own IQR of each edge, and flag anything beyond that as an individual point rather than folding it into the whisker. The multiplier is an argument in both — range in boxplot(), coef in geom_boxplot() — and setting either to 0 forces the whiskers out to the full min/max with no outlier flagging.

A twelve-value set of exam scores with one clear high value shows the full mechanic, base R first:

scores <- c(55, 60, 62, 64, 65, 67, 68, 70, 72, 74, 76, 95)
boxplot.stats(scores)
#> $stats
#> [1] 55.0 63.0 67.5 73.0 76.0
#>
#> $out
#> [1] 95

The hinges are 63 and 73, so IQR = 10 and the fences sit at 63 − 15 = 48 and 73 + 15 = 88. 95 is outside the upper fence, so it’s pulled out as $out — and, critically, the upper whisker in $stats is not the fence value 88; it’s recomputed as the largest remaining data point once 95 is excluded, which is 76. This is the part a “1.5 × IQR” summary usually skips: the fence is a boundary for deciding what’s an outlier, not itself a plotted whisker end.

ggplot2’s stat_boxplot() runs the identical two-step logic — compute quartile-based hinges, flag anything outside 1.5×IQR of them, then redraw the whisker to the nearest surviving point — just starting from its own slightly different hinges (63.5 and 72.5 here). The outlier call agrees (95 is flagged either way), but the drawn box and whisker positions differ by half a point at each edge, the same divergence as the first example.

What boxplot() actually returns

Beyond $stats and $out, the full object carries everything needed to rebuild or report the plot without re-deriving it:

str(boxplot.stats(scores))
#> List of 4
#>  $ stats: num [1:5] 55 63 67.5 73 76
#>  $ n    : int 12
#>  $ conf : num [1:2] 62.9 72.1
#>  $ out  : num 95

conf is the notch boundary around the median (see below), even when notches aren’t being drawn. For a grouped call — boxplot(y ~ group, data = df) — the returned object adds a group vector recording which box each entry in $out belongs to, so outliers from a multi-box plot can be traced back to their group programmatically instead of eyeballed off the chart.

geom_boxplot(): the ggplot2 version

ggplot2 takes a data frame and a mapped aesthetic. A numeric y with no x grouping draws a single box; the more common case maps a categorical variable to x:

library(ggplot2)
ggplot(mtcars, aes(x = factor(cyl), y = mpg)) +
  geom_boxplot()

Unlike geom_histogram(), geom_boxplot() doesn’t emit a “pick a better value” console message — there’s no binning parameter to default awkwardly — but it does need a discrete x (or an explicit group aesthetic) to draw more than one box; passing a bare numeric x collapses everything into one box regardless of its actual values, which is a common source of “why is my grouped box plot showing only one box” confusion.

Comparing groups: fill and facet_wrap()

As with histograms, mapping a grouping variable to both x and fill is the standard way to color-code groups directly on one set of axes; facet_wrap() is the alternative when there are enough groups, or enough additional structure, that side-by-side boxes on one panel get cramped:

ggplot(mtcars, aes(x = factor(cyl), y = mpg, fill = factor(cyl))) +
  geom_boxplot(show.legend = FALSE)

ggplot(mtcars, aes(x = factor(am), y = mpg)) +
  geom_boxplot() +
  facet_wrap(~ cyl)

show.legend = FALSE in the first call is worth calling out on its own: mapping the same variable to both x and fill is redundant — the group is already labeled on the x-axis — and ggplot2 will otherwise draw a second, duplicate legend for it by default.

Overlaying the raw data points

A box plot alone hides the actual sample size and distribution shape behind five summary numbers; overlaying the individual points with geom_jitter() restores that context, and is standard practice once a group has a small-to-moderate n:

ggplot(mtcars, aes(x = factor(cyl), y = mpg)) +
  geom_boxplot(outlier.shape = NA) +
  geom_jitter(width = 0.15, alpha = 0.5)

outlier.shape = NA on the boxplot layer is the important part — without it, geom_boxplot() plots its own flagged outlier points and geom_jitter() plots that same data point again, doubling it up on the chart. Setting the shape to NA suppresses only the boxplot layer’s outlier markers; every point, flagged or not, still shows up once via the jitter layer.

Notched boxplots: an approximate significance check built in

notch = TRUE is available in both boxplot() and geom_boxplot() and narrows the box around the median into a V-shaped notch, sized as median ± 1.58 × IQR / √n (McGill, Tukey & Larsen, 1978). The rule of thumb it supports: if the notches of two boxes don’t overlap, there’s reasonably strong evidence (roughly a 95% confidence level) that the two medians differ — a quick visual screen, not a substitute for an actual test, and one that gets less reliable at small n since the notch width shrinks with sample size regardless of how much data actually supports it.

boxplot(mpg ~ cyl, data = mtcars, notch = TRUE)
ggplot(mtcars, aes(x = factor(cyl), y = mpg)) + geom_boxplot(notch = TRUE)

With only 4, 7, and 14 cars per cylinder group in mtcars, expect visibly wide, uneven notches — a real illustration of why this is a screening aid for larger samples, not a formal test at this kind of n.

Saving the plot

ggsave() infers the file format from the extension and, called with no plot argument, saves whatever was drawn most recently — identical to how it works for any other ggplot2 chart:

ggplot(mtcars, aes(x = factor(cyl), y = mpg)) + geom_boxplot()
ggsave("mpg-by-cyl-boxplot.png", width = 6, height = 4, dpi = 300)

For a base R boxplot(), wrap the call between a device-opening function and dev.off() instead: png("mpg-by-cyl-boxplot.png", width = 6, height = 4, units = "in", res = 300); boxplot(mpg ~ cyl, data = mtcars); dev.off().

Common errors

  • Grouped boxplot shows only one box — the grouping variable was passed as numeric rather than a factor/character. Wrap it: factor(cyl) in ggplot2, or make sure the left side of a base R formula call is a factor column.
  • A duplicate legend appears for the same grouping variable — mapping one variable to both x and fill/colour draws a redundant legend by default; add show.legend = FALSE to the geom, since the x-axis already labels the groups.
  • Jittered points appear twice for the same outliergeom_boxplot()‘s own outlier markers weren’t suppressed. Set outlier.shape = NA on the boxplot layer whenever a separate geom_jitter()/geom_point() layer is added on top.
  • “invalid number of ‘names'” or misaligned labels — the names argument to base R’s boxplot() was given a vector that doesn’t match the number of groups actually plotted; let boxplot() derive names from the formula’s grouping factor instead of supplying them manually unless there’s a specific relabeling need.

Frequently asked questions

Why do boxplot() and geom_boxplot() show slightly different box widths on the same data?

They compute the box edges with two different, both-correct algorithms: base R’s boxplot() uses fivenum()‘s hinge rule, ggplot2’s geom_boxplot() uses ordinary type-7 quantiles via quantile(). They agree exactly for some sample sizes and differ by a fraction of a unit for others — see the worked example above. Neither is wrong; they’re answering “where’s the 25th/75th percentile” with two documented, different conventions.

How do I change the whisker length in a box plot in R?

The multiplier on the IQR is range in base R’s boxplot() and coef in ggplot2’s geom_boxplot() — both default to 1.5. Set either to 0 to force whiskers out to the full data range with no outliers flagged, or to a larger number to flag fewer points as outliers.

How do I get R to list the actual outlier values from a box plot?

boxplot.stats(x)$out in base R. For a ggplot2 plot, either compute it the same way directly on the data, or extract it from the built plot object with ggplot_build(p)$data[[1]]$outliers.

How do I overlay individual data points on a box plot without doubling the outliers?

Add outlier.shape = NA to geom_boxplot() before layering geom_jitter() or geom_point() on top — otherwise the flagged outliers get drawn twice, once by each layer.

What does a notched box plot in R actually mean?

The notch marks an approximate 95% confidence interval around the median (width ±1.58 × IQR/√n, McGill, Tukey & Larsen 1978). Non-overlapping notches between two boxes are a reasonably strong visual signal the medians differ; it’s a quick screen, not a formal hypothesis test, and gets less trustworthy at small group sizes.

See also CASRAI’s guides to descriptive statistics, skewness for reading the shape a box plot only partly captures, and histograms in R for the same extract-and-build pattern applied to a different chart type, plus the research tools hub for CASRAI’s full coverage of statistical software.

Follow CASRAI

Research-administration guidance, standards updates and independent tool reviews.

Ask CASRAI · included with Regulatory Radar

Ask about Box Plots in R: boxplot() and ggplot2’s geom_boxplot()

Ask CASRAI answers research-administration questions and cites the passages behind every claim — and says so when the corpus does not cover something, instead of guessing. It comes with a Regulatory Radar subscription at $29 a month, alongside the daily digest of regulatory changes and the dashboard of what changed.

150 questions a day, on this site, over the API, or inside your own tools through the CASRAI MCP server.

Everything CASRAI publishes — this page, the dictionary, the guides and the news — stays free to read, with no account and no card.

Referenced across the research world

University of Cambridge logoColumbia University logoCrossref logoUniversity of Edinburgh logoHarvard University logoUniversity of Oxford logoPrinceton University logoStanford School of Medicine logoUniversity College London logoORCID logoUniversity of Cambridge logoColumbia University logoCrossref logoUniversity of Edinburgh logoHarvard University logoUniversity of Oxford logoPrinceton University logoStanford School of Medicine logoUniversity College London logoORCID logo
  • University of Cambridge logo
  • Columbia University logo
  • Crossref logo
  • University of Edinburgh logo
  • Harvard University logo
  • University of Oxford logo
  • Princeton University logo
  • Stanford School of Medicine logo
  • University College London logo
  • ORCID logo

View CASRAI adoption →

Regulatory Radar

Stop finding out after the fact

$29/month, cancel anytime. Daily digest updates from our analysis, a dashboard holding the same items, and a cited assistant for everything they raise.

  • Federal Register, Federal Register+, Grants.gov, Regulations.gov, NSF News, UKRI, plus CASRAI’s own published content.
  • 72,264 indexed passages, and every answer cites the ones it drew on.