Matplotlib Histograms in Python — Complete Guide
Learn to create and customize histograms in Python with Matplotlib. Covers bins, density, KDE overlay, cumulative, stacked, and saving to file.
Matplotlib's hist() function makes it straightforward to visualize how a dataset is distributed across a numeric range. This chapter covers everything from a minimal first histogram to practical techniques you will use in real projects: choosing the right number of bins, plotting density instead of raw counts, overlaying a KDE curve, comparing multiple distributions, and saving the result to a file.
Before diving in, make sure Matplotlib and NumPy are installed:
pip install matplotlib numpyIf you are new to the library, see the Matplotlib Introduction and Getting Started chapters first.
What is a Histogram?
A histogram divides a continuous numeric variable into equally-spaced intervals called bins and draws a bar for each bin whose height equals the number of observations that fall in that interval. Unlike a bar chart, which compares discrete categories, a histogram reveals the shape of a distribution — whether it is symmetric, skewed, bimodal, or has outliers.
Use a histogram when you want to answer questions like:
- Where is most of the data concentrated?
- Is the distribution roughly normal, or is it skewed?
- Are there gaps or multiple peaks (bimodal data)?
- Are there outliers far from the bulk of the data?
Creating a Basic Histogram
Pass a one-dimensional array or list to plt.hist() and Matplotlib chooses an automatic bin count:
import matplotlib.pyplot as plt
import numpy as np
# Reproducible example: 1 000 values from a normal distribution
rng = np.random.default_rng(42)
data = rng.normal(loc=0, scale=1, size=1000)
plt.hist(data)
plt.title('Basic Histogram')
plt.xlabel('Value')
plt.ylabel('Frequency')
plt.tight_layout()
plt.show()plt.tight_layout() prevents axis labels from being clipped — a good habit to add before every show() or savefig() call.
Choosing the Number of Bins
The bins parameter is the most important tuning knob for a histogram. Too few bins hide structure; too many bins create noise.
import matplotlib.pyplot as plt
import numpy as np
rng = np.random.default_rng(42)
data = rng.normal(loc=0, scale=1, size=1000)
fig, axes = plt.subplots(1, 3, figsize=(12, 4))
for ax, n_bins in zip(axes, [5, 30, 100]):
ax.hist(data, bins=n_bins, color='steelblue', edgecolor='white')
ax.set_title(f'bins={n_bins}')
ax.set_xlabel('Value')
ax.set_ylabel('Frequency')
plt.suptitle('Effect of Bin Count', y=1.02)
plt.tight_layout()
plt.show()Practical guidelines:
- 5–10 bins — useful for very small datasets (n < 50) or quick overviews.
- 20–50 bins — good default for most datasets (n = 100–10 000).
- 50–100+ bins — appropriate for large datasets (n > 10 000) where fine structure matters.
- Pass a string rule instead of a number:
bins='auto',bins='fd'(Freedman–Diaconis), orbins='sturges'— Matplotlib delegates to NumPy'snp.histogram_bin_edges().
You can also pass an explicit list of bin edges for non-uniform spacing:
import matplotlib.pyplot as plt
import numpy as np
rng = np.random.default_rng(42)
data = rng.exponential(scale=2, size=1000)
# Finer bins near 0, coarser bins in the tail
edges = [0, 0.5, 1, 1.5, 2, 3, 4, 6, 8, 12]
plt.hist(data, bins=edges, color='darkorange', edgecolor='white')
plt.title('Custom Bin Edges (Exponential Data)')
plt.xlabel('Value')
plt.ylabel('Frequency')
plt.tight_layout()
plt.show()Customizing Appearance
Color, Transparency, and Edge
import matplotlib.pyplot as plt
import numpy as np
rng = np.random.default_rng(42)
data = rng.normal(loc=5, scale=1.5, size=800)
plt.hist(
data,
bins=30,
color='steelblue',
edgecolor='white', # thin white line between bars
linewidth=0.5,
alpha=0.85, # slight transparency
)
plt.title('Styled Histogram')
plt.xlabel('Value')
plt.ylabel('Frequency')
plt.tight_layout()
plt.show()alpha (0 = fully transparent, 1 = fully opaque) is especially useful when overlaying multiple histograms so bars do not hide each other.
Removing Chart Junk
Removing the top and right spines gives the histogram a cleaner look:
import matplotlib.pyplot as plt
import numpy as np
rng = np.random.default_rng(42)
data = rng.normal(loc=0, scale=1, size=1000)
fig, ax = plt.subplots(figsize=(8, 4))
ax.hist(data, bins=30, color='cornflowerblue', edgecolor='white')
ax.set_title('Clean Histogram')
ax.set_xlabel('Value')
ax.set_ylabel('Frequency')
ax.spines['top'].set_visible(False)
ax.spines['right'].set_visible(False)
plt.tight_layout()
plt.show()Density Histograms
By default hist() shows raw counts on the y-axis. Pass density=True to normalize the y-axis so that the total area of all bars equals 1. This turns the histogram into a probability density estimate, making it easy to compare datasets of different sizes.
import matplotlib.pyplot as plt
import numpy as np
rng = np.random.default_rng(42)
data = rng.normal(loc=0, scale=1, size=1000)
plt.hist(data, bins=30, density=True, color='mediumseagreen', edgecolor='white')
plt.title('Density Histogram')
plt.xlabel('Value')
plt.ylabel('Probability Density')
plt.tight_layout()
plt.show()Note: the y-axis values are densities, not probabilities. Multiply a density by the bin width to get the probability for that bin.
Overlaying a KDE Curve
A kernel density estimate (KDE) is a smooth curve that approximates the underlying probability distribution. Overlaying it on a density histogram gives an intuitive picture of the distribution shape. Use scipy.stats.gaussian_kde to compute it:
import matplotlib.pyplot as plt
import numpy as np
from scipy.stats import gaussian_kde
rng = np.random.default_rng(42)
data = rng.normal(loc=0, scale=1, size=1000)
fig, ax = plt.subplots(figsize=(8, 4))
# Density histogram
ax.hist(data, bins=30, density=True,
color='steelblue', edgecolor='white', alpha=0.6, label='Histogram')
# KDE curve
xs = np.linspace(data.min(), data.max(), 300)
kde = gaussian_kde(data)
ax.plot(xs, kde(xs), color='navy', linewidth=2, label='KDE')
ax.set_title('Histogram with KDE Overlay')
ax.set_xlabel('Value')
ax.set_ylabel('Probability Density')
ax.legend()
plt.tight_layout()
plt.show()Install SciPy if it is not already available:
pip install scipyComparing Multiple Distributions
To compare two or more distributions on the same axes, call hist() multiple times and use alpha to keep the bars semi-transparent. Set the same bins for both so the bar widths are comparable:
import matplotlib.pyplot as plt
import numpy as np
rng = np.random.default_rng(42)
group_a = rng.normal(loc=0, scale=1, size=500)
group_b = rng.normal(loc=2, scale=1.5, size=500)
shared_bins = np.linspace(-5, 8, 40)
plt.hist(group_a, bins=shared_bins, alpha=0.6, color='steelblue', label='Group A')
plt.hist(group_b, bins=shared_bins, alpha=0.6, color='darkorange', label='Group B')
plt.title('Overlapping Histograms')
plt.xlabel('Value')
plt.ylabel('Frequency')
plt.legend()
plt.tight_layout()
plt.show()Defining shared_bins via np.linspace() ensures both histograms use identical bin edges and their bars align visually.
Side-by-Side (Stacked) Histograms
When overlap makes it hard to read individual distributions, use plt.subplots() to place them side by side:
import matplotlib.pyplot as plt
import numpy as np
rng = np.random.default_rng(42)
group_a = rng.normal(loc=0, scale=1, size=500)
group_b = rng.normal(loc=2, scale=1.5, size=500)
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10, 4), sharey=True)
ax1.hist(group_a, bins=30, color='steelblue', edgecolor='white')
ax1.set_title('Group A')
ax1.set_xlabel('Value')
ax1.set_ylabel('Frequency')
ax2.hist(group_b, bins=30, color='darkorange', edgecolor='white')
ax2.set_title('Group B')
ax2.set_xlabel('Value')
plt.suptitle('Side-by-Side Histograms')
plt.tight_layout()
plt.show()sharey=True locks both subplots to the same y-axis scale so the bar heights are directly comparable. See the Matplotlib Subplots chapter for more layout options.
Cumulative Histograms
Pass cumulative=True to draw a histogram where each bar represents the total count of observations up to and including that bin. This is useful for answering questions like "what fraction of values are below X?":
import matplotlib.pyplot as plt
import numpy as np
rng = np.random.default_rng(42)
data = rng.normal(loc=0, scale=1, size=1000)
plt.hist(data, bins=30, cumulative=True, density=True,
color='mediumpurple', edgecolor='white')
plt.title('Cumulative Density Histogram')
plt.xlabel('Value')
plt.ylabel('Cumulative Probability')
plt.tight_layout()
plt.show()Combined with density=True, the cumulative histogram becomes a step-function approximation of the empirical CDF (cumulative distribution function). The y-axis runs from 0 to 1.
Histogram Orientation
Pass orientation='horizontal' to draw bars extending left from the y-axis. This is rarely the default choice, but it mirrors the layout of a scatter plot's marginal distribution plots:
import matplotlib.pyplot as plt
import numpy as np
rng = np.random.default_rng(42)
data = rng.normal(loc=0, scale=1, size=500)
plt.hist(data, bins=20, orientation='horizontal',
color='tomato', edgecolor='white')
plt.title('Horizontal Histogram')
plt.ylabel('Value')
plt.xlabel('Frequency')
plt.tight_layout()
plt.show()Saving a Histogram to a File
Use plt.savefig() before plt.show() (or instead of it). Specify format via the file extension:
import matplotlib.pyplot as plt
import numpy as np
rng = np.random.default_rng(42)
data = rng.normal(loc=0, scale=1, size=1000)
plt.hist(data, bins=30, color='steelblue', edgecolor='white')
plt.title('Distribution of Sample Data')
plt.xlabel('Value')
plt.ylabel('Frequency')
plt.tight_layout()
plt.savefig('histogram.png', dpi=150) # raster, good for web
plt.savefig('histogram.pdf') # vector, good for print/publication
plt.show()Common formats: png, pdf, svg, eps. Use svg or pdf when you need a scalable, print-quality image.
hist() Key Parameters — Quick Reference
| Parameter | Type | Description |
|---|---|---|
bins | int, list, or str | Number of bins, explicit edges, or rule name ('auto', 'fd', 'sturges') |
density | bool | Normalize so total area = 1 (probability density) |
cumulative | bool | Each bar shows cumulative count/density up to that bin |
orientation | str | 'vertical' (default) or 'horizontal' |
color | str | Bar fill color |
edgecolor | str | Bar border color; 'white' gives a clean separator |
alpha | float 0–1 | Transparency; set below 1 when overlaying histograms |
label | str | Legend label for this histogram |
histtype | str | 'bar' (default), 'step', 'stepfilled' |
range | (min, max) | Clip the data to this range before binning |
Common Gotchas
- Random seeds.
np.random.randn()produces different values each run. Usenp.random.default_rng(seed)for reproducible examples. densityvsnormed. The oldnormed=Trueparameter was removed in Matplotlib 3.x. Always usedensity=True.- Comparing histograms with different sample sizes. Raw frequency bars are not comparable when group sizes differ — use
density=Trueto normalize both. - Discrete integer data. For integers (e.g., dice rolls, survey ratings), set bin edges to half-integers —
bins=[0.5, 1.5, 2.5, ..., 6.5]— so each integer lands in exactly one bin with no edge ambiguity. plt.show()clears the figure. If you callshow()and thensavefig(), you get a blank file. Always callsavefig()beforeshow().
Related Chapters
- Matplotlib Introduction — library overview and installation
- Matplotlib Getting Started — first plot walkthrough
- Matplotlib Bar Charts — comparing discrete categories
- Matplotlib Scatter Plots — relationships between two variables
- Matplotlib Pie Charts — part-to-whole proportions
- Matplotlib Labels — axis labels, titles, and annotations
- Matplotlib Subplots — multiple charts in one figure
- Data Distribution — statistical background on distributions