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

Histograms in R: hist() and ggplot2’s geom_histogram()

A runnable R workflow for histograms: base hist() and its breaks argument (numeric, vector, or Sturges/Scott/FD), the unequal-width-bin freq warning, silent NA handling, and ggplot2’s geom_histogram() — binwidth vs bins, group comparison with fill and facet_wrap(), and density overlays. Every output block is real R 4.6.1 / ggplot2 4.0.3 console output.

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 to hist() directly. Histograms are for continuous or count data; for a categorical variable, use barplot(table(x)) or ggplot2’s geom_bar() instead.
  • ggplot2 plot renders blank or as a single bar — almost always a units mismatch between binwidth and the actual scale of the data (e.g. binwidth = 5 on a variable that ranges from 0 to 1), which produces either one giant bin or thousands of empty ones. Check range(x) first and pick a binwidth proportional to it.
  • argument 'freq' is not made use of — see the unequal-width bin pitfall above; not a bug, hist() deliberately overriding freq = TRUE to 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.

Follow CASRAI

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

Ask CASRAI · included with Regulatory Radar

Ask about Histograms in R: hist() and ggplot2’s geom_histogram()

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.