Matplotlib fill_between: Shading Between Two Curves

fill_between shades the area between two curves in Matplotlib. Give it the x values and one y series and it fills down to zero; give it two and it fills the gap between them.

ax.fill_between(x, y)                      # curve down to zero
ax.fill_between(x, lower, upper, alpha=0.3)  # a band between two curves

The two arguments that cause trouble are where=, for shading only part of the range, and interpolate=True, which you almost always want alongside it.

Plots and output below come from Matplotlib 3.11.2 on Python 3.12.5.

Filling between a curve and zero

With a single y series the second boundary defaults to zero:

import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np

x = np.linspace(0, 10, 200)
y = np.sin(x)

fig, ax = plt.subplots(figsize=(6, 3))
ax.plot(x, y)
ax.fill_between(x, y)                 # y2 defaults to 0

collection = ax.collections[0]
print("filled between the curve and:", collection.get_paths()[0].vertices[0][1])
print("patches added:", len(ax.collections))
plt.close(fig)

Output:

filled between the curve and: 0.0
patches added: 1
Three Matplotlib sine plots showing a basic fill_between, conditional shading with visible gaps, and the same shading with interpolate set to True
Basic fill, then where= with and without interpolate=True.

fill_between adds a collection rather than a line, which is why it sits behind your plotted curve by default and why ax.collections is where it turns up.

Filling between two curves

Pass both boundaries and Matplotlib fills the space between them. This is the shape of every error band and forecast range:

import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np

x = np.linspace(0, 10, 200)
upper = np.sin(x) + 0.4
lower = np.sin(x) - 0.4

fig, ax = plt.subplots(figsize=(6, 3))
ax.plot(x, np.sin(x), color="navy")
ax.fill_between(x, lower, upper, alpha=0.3, color="navy")

print("band width at x=0:", round(upper[0] - lower[0], 2))
print("alpha used        :", ax.collections[0].get_alpha())
plt.close(fig)

Output:

band width at x=0: 0.8
alpha used        : 0.3

alpha is what keeps your curve readable through the shading. Values between 0.2 and 0.4 work for a single band, and you’ll want lower when bands overlap.

fill_between where= for part of the range

where= takes a boolean array and fills only where it’s true. The classic use is colouring gains and losses differently:

import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np

x = np.linspace(0, 10, 40)          # a coarse grid makes the gap obvious
y = np.sin(x)

fig, ax = plt.subplots(figsize=(6, 3))
ax.plot(x, y, color="black", linewidth=1)

# without interpolate the fill stops at the last grid point, not at the crossing
ax.fill_between(x, y, 0, where=(y > 0), color="green", alpha=0.4)
ax.fill_between(x, y, 0, where=(y < 0), color="red", alpha=0.4,
                interpolate=True)

print("points above zero:", int((y > 0).sum()))
print("points below zero:", int((y < 0).sum()))
print("collections drawn:", len(ax.collections))
plt.close(fig)

Output:

points above zero: 24
points below zero: 15
collections drawn: 2
Command Prompt showing how many points of a sine curve are above and below zero and how many fill collections were drawn
Two collections, one for each side of zero.

Now look at the middle panel of the figure above. Without interpolate=True the shading stops at the last grid point before the crossing, leaving a white notch at every zero.

interpolate=True works out where the curves actually cross and fills right up to it. On a coarse x grid the difference is obvious; on a fine one it’s subtle but still there.

Reach for it whenever where= involves a comparison between two changing series, not just a fixed threshold.

Confidence bands with fill_between

This is what most people came for: a mean line with a shaded spread around it.

import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np

rng = np.random.default_rng(0)
x = np.arange(0, 30)
runs = rng.normal(loc=np.linspace(10, 25, 30), scale=2.0, size=(50, 30))

mean = runs.mean(axis=0)
sd = runs.std(axis=0)

fig, ax = plt.subplots(figsize=(6, 3))
ax.plot(x, mean, color="#0b6bcb", label="mean")
ax.fill_between(x, mean - sd, mean + sd, alpha=0.25, color="#0b6bcb",
                label="±1 sd")
ax.legend()

print("mean at x=0 :", round(mean[0], 2))
print("band at x=0 :", round(mean[0] - sd[0], 2), "to", round(mean[0] + sd[0], 2))
plt.close(fig)

Output:

mean at x=0 : 9.76
band at x=0 : 7.72 to 11.8
Command Prompt showing the mean value and the standard deviation band boundaries computed from fifty simulated runs
The numbers behind the shading: mean, and the band it sits in.
A Matplotlib line chart with a mean line and two nested shaded bands showing one and two standard deviations
Two calls, two alphas: the inner band is ±1 sd, the outer ±2 sd.

Draw the wider band first, or give it a lower alpha, otherwise you won’t see the inner one at all.

The same pattern covers percentile ranges and prediction intervals. Only the arithmetic behind the boundaries changes, so you can reuse the plotting code as-is.

fill_betweenx and flat bands

fill_betweenx is the same idea rotated: you pass y and two x boundaries. For a plain horizontal stripe there’s a simpler option:

import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np

y = np.linspace(0, 10, 100)

fig, ax = plt.subplots(figsize=(5, 3))
ax.plot(np.sin(y), y)
ax.fill_betweenx(y, np.sin(y), 0, alpha=0.3)      # x1 and x2, not y1 and y2

# a flat horizontal band, which is what most people want axhspan for
ax.axhspan(4, 6, color="orange", alpha=0.2)

print("fill_betweenx collections:", len(ax.collections))
print("axhspan added a patch    :", len(ax.patches))
plt.close(fig)

Output:

fill_betweenx collections: 1
axhspan added a patch    : 1
WantUse
Area between two curvesax.fill_between(x, y1, y2)
The same, horizontallyax.fill_betweenx(y, x1, x2)
A flat horizontal bandax.axhspan(low, high)
A flat vertical bandax.axvspan(left, right)
Only part of the rangeadd where= and interpolate=True

axhspan and axvspan span the whole axes regardless of the data, so they’re better for marking a threshold or a date range than anything tied to tick positions.

If you are styling the rest of the figure, these help too:

Frequently asked questions

What does fill_between do in Matplotlib?

It shades the area between two y series across the same x values, or between one series and zero. The arguments are in the fill_between reference.

How do I shade only part of the plot?

Pass a boolean array to where=. Add interpolate=True so the fill reaches the actual crossing point rather than the nearest data point.

Why are there gaps in my fill_between shading?

Because where= without interpolate=True stops at grid points. The gaps appear wherever the condition changes between two samples.

How do I make the fill transparent?

Use alpha, typically 0.2 to 0.4. Lower it further when two bands overlap.

How do I draw a confidence band?

Plot the mean, then ax.fill_between(x, mean - sd, mean + sd, alpha=0.3). Add a second call for a wider interval.

What is the difference between fill_between and fill_betweenx?

fill_between takes x and two y boundaries; fill_betweenx takes y and two x boundaries. Use the second for horizontally oriented plots.

How do I shade a fixed horizontal band?

ax.axhspan(low, high) spans the full width of the axes, which is simpler than building boundary arrays.