Written and maintained by CASRAI Editorial Board
Last updated
Base R’s hist(x) draws a histogram straight from a numeric vector with no setup, choosing bin count via the Sturges rule by default; ggplot2::geom_histogram() is the layered-grammar alternative, needed the moment you want to compare groups by fill or facet, add a density overlay, or match a house plotting style. Both are covered here as one page because they answer the same practical question — “how many bins, and how do I control that” — with genuinely different mechanics and different failure modes.
For the statistical logic of what a histogram shows and how to choose bin width in general, see CASRAI’s guide to the histogram first and come back here to build one in R specifically. Building the same chart in another package? See SPSS or Stata.
Every code block below was run in R 4.6.1 with ggplot2 4.0.3, using the built-in airquality dataset (New York air quality measurements, daily, May–September 1973) — a real, publicly documented dataset that also happens to contain missing values, which matters for one of the pitfalls below. Output blocks are the actual console result, not a stylised approximation.
hist(): the one-line base R histogram
hist() needs only a numeric vector. It plots immediately and, less obviously, also returns an object — assign it to inspect the bin boundaries and counts it used without re-reading them off the plot.
hist(airquality$Temp)
Assigning the call (and suppressing the plot with plot = FALSE to inspect it without a graphics device) shows what actually got computed:
h <- hist(airquality$Temp, plot = FALSE)
h$breaks
#> [1] 55 60 65 70 75 80 85 90 95 100
h$counts
#> [1] 8 10 15 19 33 34 20 12 2
Nine bins, each 5 degrees wide, chosen automatically. That count is the Sturges rule (roughly ceiling(log2(n) + 1) bins), R’s default algorithm — reasonable for a first look, but not something you should assume is the “right” bin count for a figure going into a manuscript.
Controlling bin count and width with breaks
The breaks argument accepts three different kinds of value, and hist() treats each one differently — this is the single most common point of confusion with the function.
- A single number — a suggested bin count, not a guarantee.
hist()treats it as a target and adjusts to produce “nice” round bin boundaries, so the actual bin count can differ from what you asked for. - A vector of exact breakpoints — gives you full manual control over bin boundaries, including unequal widths (see the pitfall below).
- A named algorithm string —
"Sturges"(the default),"Scott", or"FD"(Freedman-Diaconis), each a different rule for picking bin width from the data’s spread.
h2 <- hist(airquality$Temp, breaks = 20, plot = FALSE)
length(h2$counts)
#> [1] 21
Asked for 20, got 21 — a direct demonstration that a numeric breaks value is a suggestion R is free to round to cleaner boundaries, not an exact bin count. If you need an exact number of bins, pass an explicit breakpoint vector instead:
hist(airquality$Temp, breaks = seq(55, 100, length.out = 21))
The two named-algorithm alternatives, run on the same data:
length(hist(airquality$Temp, breaks = "Scott", plot = FALSE)$counts)
#> [1] 9
length(hist(airquality$Temp, breaks = "FD", plot = FALSE)$counts)
#> [1] 9
Both land on 9 bins here, same as the Sturges default — not guaranteed on every dataset, but a sign the distribution isn’t sensitive to which rule you pick. When the three rules disagree noticeably, that disagreement is itself informative: it usually means the sample is small or has an unusual shape (heavy skew, a long tail, or a few extreme outliers) that the different rules weight differently.
The unequal-width bin pitfall
Pass a breakpoint vector with bins of different widths and set freq = TRUE, and hist() silently overrides you:
hist(airquality$Temp, breaks = c(56, 70, 75, 80, 97), freq = TRUE)
#> Warning message:
#> In plot.histogram(r, freq = freq, ...) :
#> argument 'freq' is not made use of
With unequal bin widths, plotting raw counts (freq = TRUE) would make wider bins look visually bigger even at the same density — a genuinely misleading picture — so hist() forces density scaling instead and warns you about it. If a reviewer or reader ever needs the raw counts from unequal bins, read them off h$counts directly rather than trying to force a frequency-scaled plot.
Missing values: hist() vs. the rest of R
airquality$Ozone has real missing data — 37 of 153 values are NA. Unlike mean() or sd(), which return NA unless you add na.rm = TRUE, hist() drops non-finite values on its own, with no argument needed and no warning:
sum(is.na(airquality$Ozone))
#> [1] 37
h5 <- hist(airquality$Ozone, plot = FALSE)
length(h5$counts)
#> [1] 9
That’s convenient, but it also means hist() will never warn you that a fifth of your sample was silently excluded — check sum(is.na(x)) yourself and report it, since a caption showing a histogram with no stated n gives a reader no way to know how much data actually went into the shape they’re looking at.
What hist() actually returns
The object behind the plot is a list with class "histogram", and it’s worth inspecting directly rather than only ever using the side-effect plot:
str(h)
#> List of 6
#> $ breaks : int [1:10] 55 60 65 70 75 80 85 90 95 100
#> $ counts : int [1:9] 8 10 15 19 33 34 20 12 2
#> $ density : num [1:9] 0.0105 0.0131 0.0196 0.0248 0.0431 ...
#> $ mids : num [1:9] 57.5 62.5 67.5 72.5 77.5 82.5 87.5 92.5 97.5
#> $ xname : chr "airquality$Temp"
#> $ equidist: logi TRUE
#> - attr(*, "class")= chr "histogram"
mids and counts together are exactly what you need to rebuild the same histogram as a bar chart in another tool, or to hand the binned data to a colleague without sharing the raw values — useful when the underlying data is sensitive but the distribution shape isn’t.
geom_histogram(): the ggplot2 version
ggplot2 takes a data frame and a mapped aesthetic rather than a bare vector, and it always tells you what bin width it picked if you don’t specify one:
library(ggplot2)
ggplot(airquality, aes(x = Temp)) + geom_histogram()
#> `stat_bin()` using `bins = 30`. Pick better value `binwidth`.
That message is not an error — the plot still renders — but it’s ggplot2 telling you the 30-bin default is arbitrary and it expects you to override it. Do that with either binwidth (bin width in data units, usually the more interpretable choice) or bins (a target bin count, same rounding behavior as base R’s numeric breaks):
ggplot(airquality, aes(x = Temp)) + geom_histogram(binwidth = 5)
With binwidth = 5 on the same data, ggplot2 produces the same 9 bins base R’s default did, with identical boundaries — confirming the two functions agree on the underlying binning math once you tell them the same bin width:
#> xmin xmax count
#> 1 52.5 57.5 4
#> 2 57.5 62.5 9
#> 3 62.5 67.5 12
#> 4 67.5 72.5 14
#> 5 72.5 77.5 29
Comparing groups: fill and facet_wrap()
This is the practical reason to reach for ggplot2 over base R: comparing a distribution across groups in one figure. Two approaches, and they answer different questions:
airquality$MonthF <- factor(airquality$Month, labels = c("May","Jun","Jul","Aug","Sep"))
# overlapping, semi-transparent, same panel — good for 2-3 groups
ggplot(airquality, aes(x = Temp, fill = MonthF)) +
geom_histogram(binwidth = 5, alpha = 0.6, position = "identity")
# one panel per group — scales without overlapping colors piling up
ggplot(airquality, aes(x = Temp, fill = MonthF)) +
geom_histogram(binwidth = 5) +
facet_wrap(~ MonthF)
position = "identity" is required for overlapping semi-transparent histograms — ggplot2’s default position = "stack" would stack the bars for each month on top of each other instead of overlaying them, which answers a different (and for this purpose, less useful) question. Past 3-4 groups, overlapping fills become unreadable regardless of transparency; facet_wrap() scales to more groups because each gets its own panel on a shared x-axis, at the cost of making direct visual overlap comparison harder.
Overlaying a density curve
Comparing a raw-count histogram to a smoothed density curve on the same axes needs both scaled to density rather than count — after_stat(density) on the y aesthetic does that:
ggplot(airquality, aes(x = Temp, y = after_stat(density))) +
geom_histogram(binwidth = 5, fill = "steelblue", color = "white") +
geom_density(color = "firebrick", linewidth = 1)
Without y = after_stat(density), the histogram bars are on a raw-count scale while geom_density() is always on a density scale (the area under the curve integrates to 1) — the two layers would be drawn on incompatible scales and the density curve would appear as a flat line pinned near zero.
Saving the plot
ggsave() infers the file format from the extension and, called with no plot argument, saves whatever was drawn most recently:
ggplot(airquality, aes(x = Temp)) + geom_histogram(binwidth = 5)
ggsave("temp-histogram.png", width = 6, height = 4, dpi = 300)
For a base R hist() plot, there’s no equivalent one-line save — wrap the call between a device-opening function and dev.off() instead: png("temp-histogram.png", width = 6, height = 4, units = "in", res = 300); hist(airquality$Temp); dev.off().
Common errors
'x' must be numeric— passed a factor or character column tohist()directly. Histograms are for continuous or count data; for a categorical variable, usebarplot(table(x))or ggplot2’sgeom_bar()instead.- ggplot2 plot renders blank or as a single bar — almost always a units mismatch between
binwidthand the actual scale of the data (e.g.binwidth = 5on a variable that ranges from 0 to 1), which produces either one giant bin or thousands of empty ones. Checkrange(x)first and pick abinwidthproportional to it. argument 'freq' is not made use of— see the unequal-width bin pitfall above; not a bug,hist()deliberately overridingfreq = TRUEto avoid a misleading plot.
Frequently asked questions
Why does my ggplot2 histogram look different from hist() on the same data?
Almost always a bin-width mismatch — ggplot2’s unspecified default is a flat 30 bins regardless of the data’s range, while base R’s default picks bin count from the data via the Sturges rule. Set the same binwidth in both (hist(x, breaks = seq(min(x), max(x), by = w)) and geom_histogram(binwidth = w)) to make them match exactly.
How do I fix the “stat_bin() using bins = 30” message?
It isn’t an error and the plot still renders — but to remove it, set either binwidth (a bin width in the variable’s own units) or bins (a target bin count) explicitly inside geom_histogram(), e.g. geom_histogram(binwidth = 5).
Does hist() in R automatically remove missing values?
Yes, silently, with no warning and no na.rm argument needed — unlike mean(), sd(), or t.test(). Check sum(is.na(x)) yourself before trusting a histogram’s shape, since hist() gives no indication in its output that anything was dropped.
How do I get exactly N bins in R, not R’s rounded approximation?
Pass an explicit breakpoint vector rather than a bare number: hist(x, breaks = seq(min(x), max(x), length.out = N + 1)) in base R, or fix binwidth to a value that divides the data’s range into exactly the bin count you want in ggplot2 — a plain integer for either breaks or bins is a target, not a guarantee.
Do I need a package for a basic histogram in R?
No — hist() ships in the graphics package, which loads automatically with every R session. ggplot2 is only needed for grouped comparisons, faceting, density overlays, or matching a specific plotting style, all covered above.
See also CASRAI’s guide to logistic regression in R and running a t-test in R for the same extract-and-report pattern applied to inferential procedures, and the research tools hub for CASRAI’s full coverage of statistical software.








