W3docs

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 numpy

If 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), or bins='sturges' — Matplotlib delegates to NumPy's np.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 scipy

Comparing 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

ParameterTypeDescription
binsint, list, or strNumber of bins, explicit edges, or rule name ('auto', 'fd', 'sturges')
densityboolNormalize so total area = 1 (probability density)
cumulativeboolEach bar shows cumulative count/density up to that bin
orientationstr'vertical' (default) or 'horizontal'
colorstrBar fill color
edgecolorstrBar border color; 'white' gives a clean separator
alphafloat 0–1Transparency; set below 1 when overlaying histograms
labelstrLegend label for this histogram
histtypestr'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. Use np.random.default_rng(seed) for reproducible examples.
  • density vs normed. The old normed=True parameter was removed in Matplotlib 3.x. Always use density=True.
  • Comparing histograms with different sample sizes. Raw frequency bars are not comparable when group sizes differ — use density=True to 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 call show() and then savefig(), you get a blank file. Always call savefig() before show().
Was this page helpful?