What makes a figure slow, and what to do when there are more points than pixels.
Overview
Artists, not data
matplotlib can draw a million points in under a second. It can also spend thirty seconds drawing a hundred lines if you create them the wrong way. The difference is not the amount of data. It is the number of artist objects the library has to manage.
Understanding what makes matplotlib slow -- and what does not -- lets you avoid performance problems without rewriting your plotting code. Most slowdowns come from one of three patterns: creating many small artists in a loop, using scatter when plot would do, and saving at an unnecessarily high DPI. Each has a simple fix.
This page covers the artist-count model, the difference between plot and scatter in performance, collection objects that draw many items as one artist, file-size control, animation performance, draw-time measurement and the cases where matplotlib is the wrong tool and a GPU-based library is needed.
The audience for performance tuning is smaller than for other topics. If your charts draw instantly and save quickly, you do not need this page. It becomes useful when you have large datasets, many panels, or a production pipeline that generates hundreds of figures. At that point, the difference between a one-artist line and a thousand-artist loop is the difference between a usable script and one that needs a progress bar.
The page also covers the first-run font-cache cost, which makes the first figure in a session take several seconds. That delay is not a performance problem with your code. It happens once and does not recur.
Knowing when to stop optimising is part of the skill. A chart that takes half a second to draw and will be viewed once does not need optimisation. A chart in an interactive dashboard that redraws on every slider change does. The page ends with a decision framework for when performance work is worth the effort.
matplotlib's cost is dominated by the number of artists it manages, not the number of data values.
One plot call with a million points creates one Line2D. A loop making two thousand calls creates two thousand artists, each with its own properties, transform and draw pass. And that is far slower even with a fraction of the data.
The rule that follows is the same one as NumPy and pandas: pass arrays, don't loop. Where a loop is unavoidable, LineCollection and its relatives draw many segments as a single artist.
Worth knowing
Cost grows with the number of artists, not data points — one call with arrays beats a loop of calls by a wide margin.
plot with a marker is faster than scatter when size and colour do not vary.
Past a few thousand points there are more points than pixels, and the extras cannot be seen at all.
Subsampling drops genuine spikes; binning to a min/max envelope keeps them.
rasterized=True stores one artist as pixels while text and axes stay vector — a much smaller PDF with sharp labels.
Creating a figure is the expensive part of a drawing loop; reuse it with set_ydata, or at least close it.
Performance and Large Data
What makes a figure slow. What to do when there are more points than pixels.
Drawing cost grows with artists, not data
One line of a million points is fast; a million lines is not.
example_01.pymatplotlib
import matplotlib.pyplot as plt
import numpy as np
import time
rng = np.random.default_rng(0)
n = 20_000
x, y = rng.random(n), rng.random(n)
def timed(fn):
t = time.perf_counter()
fig = fn()
fig.canvas.draw()
d = time.perf_counter() - t
plt.close(fig)
return d
# the first draw of the session pays for font and backend setup, which
# would otherwise be billed to whichever test ran first
warm, wax = plt.subplots(figsize=(1, 1))
wax.plot([0, 1], [0, 1])
warm.canvas.draw()
plt.close(warm)
def one_line():
fig, ax = plt.subplots(figsize=(4, 2)); ax.plot(x, y, linewidth=0.2)
return fig
def many_calls():
fig, ax = plt.subplots(figsize=(4, 2))
for i in range(0, 2000, 1):
ax.plot(x[i:i+2], y[i:i+2], color="C0")
return fig
one = timed(one_line)
many = timed(many_calls)
print("1 call, %d points : %.3f s" % (n, one))
print("2000 calls, 4000 points : %.3f s" % many)
print("ratio : %.1fx" % (many / max(one, 1e-9)))
print()
print("The second draws a FIFTH of the data and takes longer, because")
print("each call creates an artist to manage and draw. Passing arrays")
print("to one call is the single biggest speed factor.")
fig, ax = plt.subplots(figsize=(5, 2.5))
ax.plot(x[:2000], y[:2000], linewidth=0.2)
ax.set_title("one call")
Output
scatter versus plot for many points
The uniform case is faster, because there is less to vary.
example_02.pymatplotlib
import matplotlib.pyplot as plt
import numpy as np
import time
rng = np.random.default_rng(1)
n = 50_000
x, y = rng.random(n), rng.random(n)
def timed(make):
t = time.perf_counter()
fig = make(); fig.canvas.draw()
d = time.perf_counter() - t
plt.close(fig); return d
def with_scatter():
fig, ax = plt.subplots(figsize=(4, 2)); ax.scatter(x, y, s=2); return fig
def with_plot():
fig, ax = plt.subplots(figsize=(4, 2))
ax.plot(x, y, "o", markersize=1.5); return fig
print("scatter, %d points : %.3f s" % (n, timed(with_scatter)))
print("plot , %d points : %.3f s" % (n, timed(with_plot)))
print()
print("scatter carries a per-point size and colour even when they are")
print("all the same. plot draws one uniform collection, so it is")
print("faster - use it when the markers do not need to vary.")
fig, ax = plt.subplots(figsize=(5, 2.5))
ax.plot(x[:5000], y[:5000], "o", markersize=1, alpha=0.3)
ax.set_title("plot with markers")
Output
More points than pixels
Beyond a certain n, the extra points cannot be seen at all.
example_03.pymatplotlib
import matplotlib.pyplot as plt
import numpy as np
rng = np.random.default_rng(2)
n = 200_000
x = np.sort(rng.uniform(0, 100, n))
y = np.sin(x / 5) + rng.normal(0, 0.3, n)
fig, (a, b) = plt.subplots(2, 1, figsize=(7.5, 4))
a.plot(x, y, linewidth=0.3)
a.set_title("%d points" % n)
step = n // 2000
a2 = b.plot(x[::step], y[::step], linewidth=0.8)
b.set_title("every %dth point (%d shown)" % (step, len(x[::step])))
print("A 7-inch figure at 100 dpi is 700 pixels wide.")
print("200,000 points is roughly 285 per pixel column - 284 of which")
print("are drawn on top of each other and cannot be distinguished.")
print()
print("Subsampling changes the file size and the draw time, and the")
print("chart looks the same. It does hide genuine spikes, so for")
print("min/max envelopes, aggregate rather than sample.")
Output
A seven-inch figure at 100 dpi is 700 pixels wide. Two hundred thousand points is roughly 285 per pixel column. 284 of them are drawn just on top of others.
They cost time and file size and contribute nothing visible.
Reducing them isn't a compromise. It's dropping a thing the reader could never see. Two approaches, and the gap matters.
Subsampling — x[::step] — is trivial and drops what it doesn't land on. A single-sample spike disappears. For smooth, dense data that is fine. For anything where extremes matter it isn't.
Aggregating — binning by x and drawing the min and max in each bin as a filled band — keeps the envelope. The chart looks nearly same to the full data because the envelope is what the eye was reading anyway, and genuine spikes live.
The fourth editor puts one real spike in the data and shows subsampling losing it while binning keeps it.
For scatter data, the equal is hexbin or hist2d. This Replaces overplotted points with measured density.
Aggregate rather than sample
Binning preserves the extremes that sampling drops.
example_04.pymatplotlib
import matplotlib.pyplot as plt
import numpy as np
rng = np.random.default_rng(3)
n = 100_000
x = np.linspace(0, 100, n)
y = np.sin(x / 5) + rng.normal(0, 0.2, n)
# 50_001, not 50_000: the step below divides 50_000 exactly, so a
# spike there would survive sampling and the demo would show nothing.
y[50_001] = 6.0
bins = 800
idx = np.arange(n) // (n // bins)
xb = np.array([x[idx == i].mean() for i in range(bins)])
lo = np.array([y[idx == i].min() for i in range(bins)])
hi = np.array([y[idx == i].max() for i in range(bins)])
fig, (a, b) = plt.subplots(2, 1, figsize=(7.5, 4), sharey=True)
step = n // bins
a.plot(x[::step], y[::step], linewidth=0.7)
a.set_title("subsampled - the spike is gone")
b.fill_between(xb, lo, hi, alpha=0.5, linewidth=0)
b.set_title("min/max per bin - the spike survives")
print("There is one real spike of 6.0 at the midpoint.")
print("subsampled maximum: %.2f" % y[::step].max())
print("binned maximum : %.2f" % hi.max())
print()
print("Sampling drops whatever it does not land on. Binning keeps the")
print("envelope, which is what a reader of a dense series looks at.")
Output
Rasterising a layer
Keeps a vector file small while leaving the text sharp.
example_05.pymatplotlib
import matplotlib.pyplot as plt
import numpy as np
import io
rng = np.random.default_rng(4)
n = 30_000
x, y = rng.random(n), rng.random(n)
sizes = {}
for raster in (False, True):
fig, ax = plt.subplots(figsize=(4, 3))
ax.scatter(x, y, s=2, alpha=0.3, rasterized=raster)
ax.set_title("rasterized=%s" % raster)
buf = io.BytesIO()
fig.savefig(buf, format="pdf", dpi=150)
sizes[raster] = len(buf.getvalue())
if not raster:
plt.close(fig)
print("PDF, %d points" % n)
print(" vector : %8d bytes" % sizes[False])
print(" rasterized : %8d bytes" % sizes[True])
print(" ratio : %.0fx smaller" % (sizes[False] / sizes[True]))
print()
print("rasterized=True stores that ARTIST as pixels while axes, ticks")
print("and text stay vector - so the labels are still sharp and the")
print("file opens instantly.")
Output
Loops that draw
The cost is per figure, and the fix is to reuse or close.
example_06.pymatplotlib
import matplotlib.pyplot as plt
import numpy as np
import time, io
rng = np.random.default_rng(5)
t = time.perf_counter()
for i in range(20):
fig, ax = plt.subplots(figsize=(3, 1.6))
ax.plot(rng.random(50))
buf = io.BytesIO(); fig.savefig(buf, format="png")
plt.close(fig)
fresh = time.perf_counter() - t
t = time.perf_counter()
fig, ax = plt.subplots(figsize=(3, 1.6))
line, = ax.plot(rng.random(50))
for i in range(20):
line.set_ydata(rng.random(50))
ax.relim(); ax.autoscale_view()
buf = io.BytesIO(); fig.savefig(buf, format="png")
reuse = time.perf_counter() - t
plt.close(fig)
print("new figure each time : %.3f s" % fresh)
print("reuse and set_ydata : %.3f s" % reuse)
print("ratio : %.1fx" % (fresh / max(reuse, 1e-9)))
print()
print("Creating a figure is the expensive part. When the layout does")
print("not change, update the artist's data and re-save.")
fig, ax = plt.subplots(figsize=(4, 2))
ax.bar(["fresh", "reuse"], [fresh, reuse])
ax.set_title("seconds for 20 charts")
Output
plot versus scatter
For many points that all look alike, ax.plot(x, y, "o") is faster than ax.scatter(x, y).
scatter builds a set that carries a size and colour per point. That is what it's for. When those don't vary, the machinery is unused and paid for anyway.
So: scatter when size or colour codes a thing, plot when it doesn't.
File size
Vector formats store each element. So A scatter of 30,000 points is 30,000 objects in the PDF. Such files are slow to open and large.
rasterized=True on the artist stores that layer as pixels while all else. Axes, ticks, labels, title — stays vector:
ax.scatter(x, y, s=2, rasterized=True)
fig.savefig("out.pdf", dpi=200)
The result is a small file with sharp text and a pixel-based data layer. This Is just the right trade for a dense scatter in a doc. The dpi at save time controls the detail of the rasterised part.
Drawing loops
making many similar charts — one per group, per day, per file — spends most of its time creating figures, not drawing data.
Two levels of fix.
Close each figure: plt.close(fig). This is not an optimisation so much as a requirement; without it the figures accumulate, matplotlib warns after twenty, and memory grows without bound.
Reuse the figure: create it once, update the artist's data each iteration, and re-save:
relim and autoscale_view are needed because setting data doesn't rescale the axes.
This is usefully faster when the layout is same between charts. It's the same method animations use.
When matplotlib is the wrong tool
matplotlib renders once and isn't built for interactive exploration of large data.
Datashader aggregates hundreds of millions of points into an image. Is the right answer above a few million.
Plotly, Bokeh, Altair give pan-and-zoom in a browser. This Matplotlib's interactive backends do only awkwardly.
mpl-scatter-density and similar handle the dense-scatter case within matplotlib.
The signal is often the size: if a figure takes more than a few seconds to draw, the answer is a other approach rather than a faster matplotlib.
Measuring draw time
Timing a matplotlib figure needs care. The drawing is lazy. Creating artists is fast and nothing is rendered until the canvas is drawn.
t = time.perf_counter()
fig.canvas.draw()
elapsed = time.perf_counter() - t
Without the clear draw(), the timing measures only object creation and reports numbers far too good.
The first draw of a session also pays for font cache and backend init. This Can be a second or more. A warm-up figure before timing anything is the gap between a real compare and a misleading one. The editors in this module do just that.
Where the time goes
For a common figure, in rough order:
Artist creation when there are many artists.
Rendering the artists to pixels.
Text layout, which is more costly than it sounds. A chart with hundreds of tick labels or annotations spends real time measuring glyphs.
Font cache building, once per setting, and at times minutes on first run.
Saving, above all to vector formats with many elements.
The hands-on results: fewer artists, fewer text objects, and rasterised layers for dense data.
Interactive versus file output
An interactive backend redraws on each pan, zoom and resize. A figure that takes two seconds to draw is unusable interactively while being just right fine as a file.
That changes the trade-off. For a saved figure, drawing a million points slowly once is acceptable. For a thing a person will change, the point budget is far smaller. Downsampling isn't an tuning but a need.
fig.canvas.draw_idle() defers a redraw until the event loop is free. This Is what interactive tools use to stay responsive.
Animations
FuncAnimation re-renders a figure per frame. So The per-frame cost is what matters.
blit=True redraws only the artists that changed. This Is much faster and needs the update function to return them.
The same reuse principle as the drawing loop applies. Create the artists once, update their data each frame with set_data. Never call plot inside the update.
The wider picture
matplotlib is designed for correctness and control rather than throughput. Its limits are reached sooner than people expect. Tens of thousands of artists, or a few million points.
The escape routes, roughly by problem:
Too many points — aggregate, or use datashader.
Too many artists — sets instead of loops.
Too slow interactively — a browser-based library.
Too many figures — reuse, and close.
The one that matters most is the first. Most speed problems in matplotlib are really a display problem. Drawing more than the reader can see. This Costs time and sends nothing.
Font cache and first-run cost
A surprise on a fresh setting: the very first matplotlib figure can take a long time, at times minutes, while the font cache is built.
It happens once per setting and is cached later. It's a common source of "matplotlib is incredibly slow" reports from people who ran it once in a new holder.
In a holder or CI image, drawing one throwaway figure at build time moves the cost out of the first real run.
matplotlib.get_cachedir() shows where it lives, and deleting it forces a rebuild. Which is the fix when a newly installed font isn't being found.
A performance checklist
When a figure is slow, in the order worth checking:
How many artists?len(ax.lines) + len(ax.sets) + len(ax.patches). Hundreds is fine. Tens of thousands is the problem.
How many points, against how many pixels? If it's more than a few per pixel column, the extras are invisible.
Is it in a loop? Are figures being closed?
Is it text? Hundreds of annotations or tick labels cost real time.
Is it the save? Vector output of dense data.
Is it the first run? The font cache.
Most slow figures are the first two. Both are fixed by drawing less rather than by drawing faster.
Budgets
Rough numbers, which are more handy than general advice.
Artists: up to a few thousand is comfortable. Tens of thousands is slow. Hundreds of thousands will not finish in a reasonable time.
Points in one artist: a million is fine for a line, because it is one object. The renderer handles it far better than a million separate objects.
Visible resolution: a 7-inch figure at 100 dpi has 700 columns. Above a few thousand points, most are invisible.
Text objects: hundreds are noticeable; thousands dominate.
Interactive redraw: anything above about 100 ms per draw feels sluggish when panning.
Vector output: above roughly 10,000 elements the file becomes slow to open.
Those thresholds explain most of the hands-on advice. Pass arrays rather than looping, aggregate before plotting, rasterise dense layers, and reuse figures in loops.
Performance and Large Data, in summary
Cost is driven by artists, not data points. One call with an array beats a loop of calls by a wide margin.
plot beats scatter when the markers are uniform.
Past a few thousand points there are more points than pixels. The extras cost time while communicating nothing.
Subsampling drops genuine extremes. Binning to a min/max envelope keeps them, and looks the same.
rasterized=True keeps a vector file small while leaving the text sharp.
Creating figures is the costly part of a drawing loop. Reuse them. And close them even so, because matplotlib won't.
And when a figure takes seconds to draw, the answer is often a other approach rather than a faster matplotlib.
When to stop optimising
A figure that takes two seconds to draw once. Is then saved, is fine. There's nothing to fix.
The cases that justify effort are narrower than they look:
A figure regenerated often — in a dashboard build, a CI job, a loop over thousands of groups. The cost times.
An interactive figure, where each pan and zoom pays it again.
A figure that doesn't finish, or exhausts memory. This Is a correctness problem rather than a speed one.
A file too large to open. This Is the vector-scatter case.
Outside those, drawing time is often a rounding error next to the data work that preceded it. The effort is better spent on whether the chart says the right thing.
That is worth stating because speed is easy to optimise and easy to over-optimise. A fast chart no one can read isn't an gain.
One more thing
fig.canvas.draw_idle() requests a redraw at the next opportunity rather than at once. This Is what keeps an interactive figure responsive when a few things change at once.
For a script it makes no gap. The draw happens at save time either way. It matters in a callback that updates a few artists. Here An direct draw per change would render the figure a few times for one logical update.
Performance and Large Data: the short version
Nearly each speed problem in matplotlib is a display problem in disguise: drawing more than the output can show.
Fixing it by drawing less. Aggregating, subsampling with care, rasterising a dense layer — makes the chart faster and often more readable also. This Is unusual among optimisations.
Questions people ask about Performance and Large Data
Why is my plot slow? Usually too many artists, such as a loop that makes thousands of lines, or too many points to be seen on screen.
How do I plot a million points? Downsample, aggregate into bins, use hexbin, or draw one plot call with an array rather than many calls.
Is scatter slower than plot? Yes, for large arrays. plot with a marker and no line draws one artist, while scatter tracks per-point properties.
What does rasterized=True do? It draws that artist as pixels inside a vector file, which keeps PDF and SVG sizes small.
How do I speed up animation? Update artist data with set_data instead of redrawing, and use blitting where the backend supports it.
How do I find out what is slow? Time the plotting calls with time.perf_counter or cProfile. Often the saving step, not drawing, dominates.
Reading the code back
speed work here is a short list checked in order. Is there a loop creating artists, are there more points than pixels, are figures being closed, is the output vector with a dense layer in it. Four questions. The first two account for nearly all. None of them needs knowing anything about how matplotlib renders.
Check yourself
0 of 4
Answer without scrolling back up.
What dominates matplotlib's drawing cost?
One plot call with a million points makes one Line2D; two thousand calls make two thousand artists, each drawn separately.
When is `plot(x, y, 'o')` faster than `scatter`?
scatter carries per-point size and colour machinery that is paid for even when unused.
Why is subsampling risky for a dense series?
Binning to a min/max envelope keeps the extremes, and the chart looks nearly the same because the envelope is what the eye reads.
What does `rasterized=True` do to a PDF?
The right trade for a dense scatter in a document - small file, sharp labels, pixel-based data layer.
Cheat sheet
Performance and Large Data
Speed up slow matplotlib figures that render too many points. Use rasterisation, line simplification and collections to stay responsive.
Ashish Jangra builds and maintains VizLearn. Every module here is written and the visualisation behind it hand-built, so the numbers in a readout come from the same code that draws the picture. Corrections are genuinely welcome and get priority over everything else — if a page states something wrong, or an animation misrepresents what the algorithm does, get in touch.