What makes a figure slow, and what to do when there are more points than pixels.
Overview
Artists, not data
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, do not 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, and 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
Output
scatter versus plot for many points
The uniform case is faster, because there is less to vary.
example_02.pymatplotlib
Output
More points than pixels
Beyond a certain n, the extra points cannot be seen at all.
example_03.pymatplotlib
Output
A seven-inch figure at 100 dpi is 700 pixels wide. Two hundred thousand points is roughly 285 per pixel column, and 284 of them are drawn exactly on top of others.
They cost time and file size and contribute nothing visible.
Reducing them is not a compromise; it is removing something the reader could never see. Two approaches, and the difference matters.
Subsampling — x[::step] — is trivial and drops whatever it does not land on. A single-sample spike disappears. For smooth, dense data that is fine; for anything where extremes matter it is not.
Aggregating — binning by x and drawing the min and max in each bin as a filled band — keeps the envelope. The chart looks nearly identical to the full data because the envelope is what the eye was reading anyway, and genuine spikes survive.
The fourth editor puts one real spike in the data and shows subsampling losing it while binning keeps it.
For scatter data, the equivalent is hexbin or hist2d, which replaces overplotted points with measured density.
Aggregate rather than sample
Binning preserves the extremes that sampling drops.
example_04.pymatplotlib
Output
Rasterising a layer
Keeps a vector file small while leaving the text sharp.
example_05.pymatplotlib
Output
Loops that draw
The cost is per figure, and the fix is to reuse or close.
example_06.pymatplotlib
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 collection that carries a size and colour per point, because that is what it is for. When those do not vary, the machinery is unused and paid for anyway.
So: scatter when size or colour encodes something, plot when it does not.
File size
Vector formats store every 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 everything 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, which is exactly the right trade for a dense scatter in a document. The dpi at save time controls the resolution of the rasterised part.
Drawing loops
Generating 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 does not rescale the axes.
This is meaningfully faster when the layout is identical between charts, and it is the same mechanism animations use.
When matplotlib is the wrong tool
matplotlib renders once and is not built for interactive exploration of large data.
Datashader aggregates hundreds of millions of points into an image, and is the right answer above a few million.
Plotly, Bokeh, Altair give pan-and-zoom in a browser, which matplotlib's interactive backends do only awkwardly.
mpl-scatter-density and similar handle the dense-scatter case within matplotlib.
The signal is usually the size: if a figure takes more than a few seconds to draw, the answer is a different approach rather than a faster matplotlib.
Measuring draw time
Timing a matplotlib figure needs care, because 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 explicit 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 initialisation, which can be a second or more. A warm-up figure before timing anything is the difference between a meaningful comparison and a misleading one — the editors in this module do exactly that.
Where the time goes
For a typical figure, in rough order:
Artist creation when there are many artists.
Rendering the artists to pixels.
Text layout, which is more expensive than it sounds — a chart with hundreds of tick labels or annotations spends real time measuring glyphs.
Font cache building, once per environment, and occasionally minutes on first run.
Saving, particularly to vector formats with many elements.
The practical consequences: fewer artists, fewer text objects, and rasterised layers for dense data.
Interactive versus file output
An interactive backend redraws on every pan, zoom and resize, so a figure that takes two seconds to draw is unusable interactively while being perfectly fine as a file.
That changes the trade-off. For a saved figure, drawing a million points slowly once is acceptable. For something a person will manipulate, the point budget is far smaller, and downsampling is not an optimisation but a requirement.
fig.canvas.draw_idle() defers a redraw until the event loop is free, which 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, which is much faster and requires 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, and never call plot inside the update.
The wider picture
matplotlib is designed for correctness and control rather than throughput, and 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 — collections 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 performance problems in matplotlib are really a display problem: drawing more than the reader can see, which costs time and communicates nothing.
Font cache and first-run cost
A surprise on a fresh environment: the very first matplotlib figure can take a long time, occasionally minutes, while the font cache is built.
It happens once per environment and is cached afterwards, and it is a common source of "matplotlib is incredibly slow" reports from people who ran it once in a new container.
In a container 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 is not being found.
A performance checklist
When a figure is slow, in the order worth checking:
How many artists?len(ax.lines) + len(ax.collections) + len(ax.patches). Hundreds is fine; tens of thousands is the problem.
How many points, against how many pixels? If it is 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, and both are fixed by drawing less rather than by drawing faster.
Budgets
Rough numbers, which are more useful 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 practical advice: pass arrays rather than looping, aggregate before plotting, rasterise dense layers, and reuse figures in loops.
In summary
Cost is driven by artists, not data points, so 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, and 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 expensive part of a drawing loop, so reuse them — and close them regardless, because matplotlib will not.
And when a figure takes seconds to draw, the answer is usually a different approach rather than a faster matplotlib.
When to stop optimising
A figure that takes two seconds to draw once, and is then saved, is fine. There is nothing to fix.
The cases that justify effort are narrower than they look:
A figure regenerated frequently — in a dashboard build, a CI job, a loop over thousands of groups. The cost multiplies.
An interactive figure, where every pan and zoom pays it again.
A figure that does not finish, or exhausts memory, which is a correctness problem rather than a speed one.
A file too large to open, which is the vector-scatter case.
Outside those, drawing time is usually a rounding error next to the data work that preceded it, and the effort is better spent on whether the chart says the right thing.
That is worth stating because performance is easy to optimise and easy to over-optimise, and a fast chart nobody can read is not an improvement.
One more thing
fig.canvas.draw_idle() requests a redraw at the next opportunity rather than immediately, which is what keeps an interactive figure responsive when several things change at once.
For a script it makes no difference, since the draw happens at save time either way. It matters in a callback that updates several artists, where an immediate draw per change would render the figure several times for one logical update.
The short version
Nearly every performance 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 usually more readable at the same time, which is unusual among optimisations.
Reading the code back
Performance 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, and the first two account for nearly everything. None of them requires 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
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.
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.