Math, Statistics, and DataData Visualization › Day 128

Day 128: Matplotlib Fundamentals

Day 128 of 365 — Matplotlib Fundamentals

After this lesson you will be able to explain why matplotlib's two APIs produce different behaviour from nearly identical-looking code, and build every chart in this course the way that cannot go wrong. You will watch a helper function built from plt.plot and plt.xlabel silently overlay two unrelated runs onto one figure when called twice -- measured directly, plt.get_fignums() reports a single figure holding two lines -- and then watch the object-API version of the same function, built from fig, ax = plt.subplots(), produce two independent figures with one line each, guaranteed by construction rather than by discipline. You will compute savefig's exact pixel arithmetic and check it against a real PNG file's own header bytes: a 6x4 inch figure at 100 dpi saves at exactly 600x400 pixels, and doubling the dpi to 200 doubles both dimensions to exactly 1200x800, measured, not assumed. You will build a 2x3 grid of subplots and confirm each Axes is genuinely independent -- a label set on one cell never appears on any other. You will discover what a logarithmic y-scale actually does to a data point with value zero: it does not raise an error, and on matplotlib 3.11.1 it does not even warn when the series contains a mix of positive and non-positive values -- it silently narrows the rendered range so the zero-valued point falls outside what gets drawn, while the underlying data is untouched. You will apply the label-then-legend pattern and verify a legend's text and order by reading the Legend object directly. You will reproduce matplotlib's figure-lifecycle leak -- opening figures in a loop without closing them -- and trigger matplotlib's own real RuntimeWarning once more than 20 are open at once, then watch plt.close() empty the registry completely. You will save the same chart as PNG and SVG and prove, by searching each file's own bytes, that the axis label appears as literal searchable text in the SVG and nowhere at all in the PNG -- the entire argument for vector output turned into two assertions. And throughout, you will test every one of these claims by asserting on the chart's own object graph -- ax.get_xlabel(), ax.get_ylim(), len(ax.lines), ax.lines[0].get_xydata(), ax.get_yscale() -- never by comparing rendered pixels to a stored reference image.

Course
Math, Statistics, and Data
Category
Data Visualization
Reading time
≈ 45 min
Practical time
≈ 35 min
Lesson duration
1h 20m
Last verified
2026-08-20

Hands-on lab for this lesson

Lab files on GitHub: https://github.com/ai-roadmap-365/ai-roadmap-365.github.io/tree/main/labs/sections/math-statistics-and-data/day-128-matplotlib-fundamentals

  1. Get the hands-on files. Clone the labs repository once (you can reuse this clone for every lesson). This works on macOS, Linux, and Windows (PowerShell or WSL):
    git clone https://github.com/ai-roadmap-365/ai-roadmap-365.github.io.git
    cd ai-roadmap-365.github.io
  2. Open this lesson's lab. Move into the directory for this specific day. Every lab lives at the same predictable path — section / subsection / week / day:
    cd labs/sections/math-statistics-and-data/day-128-matplotlib-fundamentals
  3. Read the lab guide. Open `README.md` in that directory. It lists the exact commands, what each does, the expected output, and how to check your work — read it before running anything.
  4. Run it and check your work. Follow the README's "How to run" section: run the example first to see the finished result, then complete the numbered exercises in `starter/`, then run the tests. The tests pass (exit 0) only when your work is correct.
    bash tests/run_tests.sh   # or the test command named in the lab README

You can also open the lab as a local page (works offline, shows the file tree and expected output).

Learning objectives

By the end of this lesson you will be able to:

Prerequisites

Why this matters

Somebody on your team writes a small helper to plot a training curve:

def plot_curve(losses, label):
    plt.plot(losses, label=label)
    plt.xlabel("step")
    plt.ylabel("loss")
    plt.title("training loss")
    plt.legend()

It works the first time. They call it once, save the figure, done. Two weeks later they are comparing two runs, so they call it twice:

plot_curve(run_a_losses, "run A")
plot_curve(run_b_losses, "run B")
plt.savefig("comparison.png")

They open comparison.png expecting two charts, or at worst one chart with two clearly separate curves on it. What they get instead is one chart with both curves overlaid on the same axes, which — for a training loss comparison — might even look correct at a glance. Nobody flagged an error. Nothing crashed. plt.legend() even labelled both lines properly. The only thing wrong is that this is not what anyone intended, and there is no visual cue in the output that says so.

Here is the mechanism, and it is worth sitting with because it explains matplotlib’s entire design: plt.plot(), plt.xlabel() and plt.title() are not methods on an object you created. They are functions that reach out to whichever figure and axes happen to be “current” — matplotlib’s internal notion of the thing you’re probably talking about — and draw into it. The first call to plot_curve creates a current figure, because nothing existed yet. The second call finds that same current figure still sitting there, because nothing in between asked for a new one, and draws into it too. Two calls, one figure, two lines nobody separated.

This lesson measured that exact failure for real. Calling a plt.plot-based helper twice with different data left plt.get_fignums() reporting [1] — one figure — holding two lines. Calling the equivalent helper built with fig, ax = plt.subplots() instead, twice, left plt.get_fignums() reporting [1, 2] — two figures, one line on each — every time, with no discipline required to make it happen that way. The second version cannot produce the first version’s bug, because it never asks “what’s current?” It says exactly what it means: this axes, this line.

The rule the rest of this course follows because of this: always fig, ax = plt.subplots(), always call methods on ax. Every diagram, every chart, every figure you will build from here through Day 133’s EDA report and beyond uses the object API, and this lesson is where you learn why that is not a style preference. It is the difference between a chart that can only show what you told it to show, and a chart that can quietly show something else because two pieces of code both assumed they had the canvas to themselves.

matplotlib is not a special case in the AI practitioner’s toolkit — it is the layer every training curve, every confusion matrix, every evaluation plot in this course’s later sections gets drawn through, usually inside a helper function called once per experiment, once per epoch, or once per model comparison. A plotting helper written against the pyplot state machine and called from more than one place in a training or evaluation script does not fail loudly. It produces a report where two experiments’ loss curves are silently overlaid on one figure, or where the twentieth diagnostic plot in a long training run is the one that finally trips a memory warning nobody was watching for. Neither failure shows up as a wrong number — both show up as a chart that looks plausible and is not what anyone asked for, which is exactly the kind of mistake a testable, object-graph approach to charting catches before a reader ever has to notice it themselves.

The idea in plain language

matplotlib gives you three things stacked on top of each other, and almost every confusing tutorial online exists because it never tells you which one it is talking to. A Figure is the whole canvas — the thing you save to a file. An Axes is one plotting area on that canvas, with its own x and y coordinates, its own title, its own labels. An Artist is anything actually drawn — a line, a bar, a piece of text, a legend.

A Figure can hold several Axes (that is what a grid of subplots is). Each Axes holds a list of Artists (that is what ax.lines is — a list of the Line2D objects that ax.plot() created). Nothing here is metaphorical: these are real Python objects, with real attributes, and you can read them back after the fact. ax.get_xlabel() returns the exact string you passed to ax.set_xlabel(). ax.lines[0].get_xydata() returns the exact array you plotted. This is the single most useful fact in the whole lesson — a chart, in matplotlib, is not a picture. It is a data structure that happens to render as a picture, and a data structure can be inspected, asserted on, and tested, the same way you’d test a dictionary or a list.

matplotlib gives you two ways to build that data structure. The pyplot state machineplt.plot, plt.xlabel, plt.title — is a set of convenience functions that always operate on “the current figure” and “the current axes,” tracked as hidden global state inside the pyplot module. It is genuinely convenient for a single, throwaway plot in a notebook cell: three lines, no boilerplate, done. The object APIfig, ax = plt.subplots(), then ax.plot, ax.set_xlabel, ax.set_title — makes you name the figure and axes you’re drawing into, once, and then every following call is a method on that specific object. It costs one extra line up front. What it buys back is that the code can never be ambiguous about where it is drawing, which is exactly the property that breaks in the state machine the moment a plotting routine is called more than once, or from inside a function, or from two different places in a larger program — which describes essentially all real code, and none of a notebook’s first three cells.

Historical background

matplotlib was created by John D. Hunter, a neurobiologist, starting around 2002-2003, out of frustration with the plotting tools available in the scientific Python ecosystem at the time — MATLAB was the dominant environment for this kind of plotting in academic labs, and Hunter wanted MATLAB-like plotting commands available from Python, without a MATLAB licence. The name is a portmanteau of “MATLAB” and “plot library,” and that MATLAB ancestry is exactly where the pyplot state machine comes from: plt.plot, plt.xlabel, plt.title are deliberately modelled on MATLAB’s own global-current-figure plotting commands, because that was the interface working scientists already knew.

The object-oriented API — Figure, Axes, Artist as real, addressable objects — was there from early on as the library’s actual underlying architecture; pyplot was always a thin convenience layer built on top of it, not a separate implementation. What has shifted over roughly two decades of matplotlib’s life is the advice: early tutorials, written for an audience coming from MATLAB, leaned almost entirely on the pyplot functions, because that was the familiar shape. As matplotlib’s own audience grew to include people who had never touched MATLAB, and as programs built with it grew from single notebook cells into functions, classes and long-running services, the object API’s explicitness stopped being a nice-to-have and became the documented, endorsed way to write anything beyond a single throwaway plot — which is precisely the guidance matplotlib’s own “Figures and Axes interfaces” documentation states today. Hunter died in 2012; the project is now maintained by a large open-source community (the Matplotlib Development Team, NumFOCUS-sponsored) and remains, more than twenty years on, the plotting library nearly every other Python visualization tool either builds on directly (seaborn) or positions itself as an alternative to (plotly, plotnine, Bokeh).

What it is — and what it is not

matplotlib is a 2-D (and limited 3-D) plotting library: a way to turn arrays of numbers into rendered charts, and to control every visual element of that rendering — colour, line width, tick spacing, font, DPI — in code. It is not a statistical analysis library (it does not compute a regression line or a confidence interval for you; seaborn and scipy do that, then hand matplotlib the numbers to draw), not a dashboard framework (no built-in interactivity, callbacks, or state management across user clicks — Plotly Dash and Streamlit exist for that), and not a data processing library (it plots whatever array you give it; pandas and NumPy are what produce that array).

It is also not, despite the pyplot interface’s appearance, a system that requires you to think in terms of “the current plot.” That impression — common among people who learned matplotlib from plt.plot-only tutorials — is an artifact of which corner of the API got taught first, not a description of what the library actually is underneath. Underneath, every chart is a Figure containing Axes containing Artists, addressable and inspectable as ordinary Python objects, whether or not the code that built it ever used the word pyplot.

Why it was created and what problems it solves

Before matplotlib, plotting from Python meant either shelling out to a separate tool (Gnuplot, via a wrapper) or writing bespoke, ad hoc code against whatever graphics library happened to be available, with no common conventions across projects. matplotlib solved the problem of having a plotting library at all that felt native to Python and produced publication-quality output, and it did so specifically by copying an interface (MATLAB’s) that its target audience — scientists and engineers — already had years of muscle memory for.

The problem this lesson’s opening failure describes — two APIs, one familiar and fragile, one more verbose and robust — is a second-order consequence of that founding design choice, and it is worth naming precisely because it explains a design tension every plotting library eventually has to resolve: the interface that is easiest to teach (a short sequence of global function calls, no object management) is often not the interface that is safest to build programs out of (explicit objects, explicit method calls, no hidden global state). matplotlib chose not to force a resolution — it kept both, kept the convenient one as the default in its own quick-start documentation, and left it to the individual author to learn, usually the hard way, when the convenient one stops being safe. This lesson exists to shorten that learning curve to one paragraph and one measured example, rather than one production incident.

How it works

Diagram: a single rendered line chart, labelled to show the object model -- an outer box marked Figure containing one inner plotting box marked Axes; inside the Axes, labelled parts include the four spines around the edge, tick marks and tick labels on both axes, the x-axis label and y-axis label below and beside the plot, a title above the plot, a legend box in an upper corner, and the plotted line itself marked as a Line2D Artist -- with a caption explaining that the Figure holds the Axes and the Axes owns everything drawn inside it

Start from the object you actually get back from plt.subplots():

import matplotlib
matplotlib.use("Agg")  # headless -- no window, ever
import matplotlib.pyplot as plt

fig, ax = plt.subplots(figsize=(6, 4))
ax.plot([0, 1, 2, 3], [0, 1, 4, 9])
ax.set_xlabel("x")
ax.set_ylabel("y")
ax.set_title("a parabola-ish thing")

fig is a Figure object. ax is an Axes object. ax.plot(...) creates a Line2D Artist and appends it to ax.lines — after the call above, len(ax.lines) == 1, and ax.lines[0].get_xydata() returns exactly the two arrays you passed in, unchanged. ax.set_xlabel("x") sets a Text artist’s string; ax.get_xlabel() reads it straight back. None of this is inference or approximation — it is direct attribute storage and retrieval, which is exactly why every claim in this lesson gets checked by reading these objects back rather than by looking at a rendered picture.

Saving the figure goes through fig.savefig(path, ...), and two arguments control the output size precisely. figsize=(width, height) in inches is set at plt.subplots() time (or with fig.set_size_inches afterward); dpi (dots per inch) is passed to savefig itself, or set as a Figure-level default. Multiply them and you get the output’s pixel dimensions, exactly, as long as bbox_inches='tight' is not requested:

figsize (inches)dpisaved pixel dimensions (measured)
(6, 4)100600 x 400
(6, 4)2001200 x 800
(6, 4)50300 x 200

Those three rows are not textbook numbers — they are read directly off real PNG files this lesson’s lab saved, by parsing each file’s own IHDR header bytes (a PNG’s width and height are stored as two big-endian 4-byte integers, 16 bytes into the file, after an 8-byte signature and a 4-byte chunk length). bbox_inches='tight' changes this: it crops the saved canvas to the bounding box of whatever was actually drawn, which means the final size is figsize * dpi minus whatever margin got trimmed — often exactly what you want visually, never exactly predictable from the two numbers alone.

Raster versus vector is a choice made at savefig time via the file extension or an explicit format= argument. PNG (and JPEG) are raster: the output is a fixed grid of pixel colours, good for anything photograph-like or for embedding in a place that expects a bitmap. SVG (and PDF) are vector: the output is a description of shapes and text, resolution-independent, and — for SVG specifically — literally XML markup you can open in a text editor. This lesson’s lab proved the practical consequence directly: after saving one chart as both formats, the string "depth (m)" (the axis label) was found as literal characters inside the saved SVG file, and was not found anywhere in the saved PNG file’s bytes. An SVG or PDF chart can be zoomed, printed at any size, or have its text edited after the fact; a PNG chart is exactly as sharp as the pixels it was rasterized into, forever.

Subplots are built with plt.subplots(nrows, ncols), which returns (fig, axes) where axes is a genuine 2-D NumPy array of Axes objects when either nrows or ncols exceeds 1 — shaped exactly (nrows, ncols). Measured directly: plt.subplots(2, 3) returns an axes array of .shape == (2, 3), and setting axes[0, 0].set_xlabel(...) leaves axes[0, 1].get_xlabel() as an empty string. Each cell is independent because each cell is a genuinely separate Axes object; nothing shares state between them unless you explicitly ask for it with sharex=True or sharey=True. When subplots overlap or their labels collide, fig. tight_layout() (the older, simpler option) or constrained_layout=True (passed to plt.subplots(), the newer and generally more robust option) adjusts spacing automatically.

Labels, limits, ticks and scales are all set through methods on ax: ax.set_xlabel, ax.set_title, ax.set_xlim, ax.set_xticks. Axis limits autoscale to fit the plotted data by default — call ax.set_ylim explicitly and that override sticks; autoscaling does not resume on its own. ax.set_yscale('log') switches an axis to a logarithmic scale, where equal steps represent equal ratios rather than equal differences — useful for data spanning several orders of magnitude. It has one sharp edge worth stating precisely, because it is easy to discover it the hard way in a real chart: a log scale on data containing zero or negative values does not raise an error. Measured directly on matplotlib 3.11.1: plotting [0, 1, 4, 9, 16] and switching the y-axis to log left ax.get_ylim() reporting roughly (0.87, 18.4) — a lower bound strictly above zero — while ax.lines[0].get_xydata() still contained the original (0, 0) point, untouched. The zero-valued point simply does not get drawn, because there is no y-pixel log(0) maps to. No error, and in this mixed-sign case, no warning either — matplotlib’s “Data has no positive values, and therefore cannot be log-scaled” warning only fires when every value in the series is non-positive. A chart with one silent missing point, and nothing telling you it is missing, is a genuinely easy way to misreport data.

Annotation goes through ax.text(x, y, "a note") for a plain label at a data coordinate, and ax.annotate("a note", xy=(x, y), xytext=(x2, y2), arrowprops=dict(arrowstyle="->")) for a label connected to a specific point by an arrow, with the label itself placed somewhere less crowded. One well-placed annotation pointing at the interesting spike in a curve usually communicates more, faster, than a legend forcing the reader to match five colours to five labels in a separate box.

Legends follow the label-then-legend pattern: pass label= to every plot call that should appear, then call ax.legend() once, after every relevant artist exists. Measured directly: plotting a “measured” series then a “predicted” series, each with its label set at plot time, then calling ax.legend() once, produces a Legend whose get_texts() reads back exactly ['measured', 'predicted'] — plotting order, not alphabetical order, not label-string order.

rcParams and style sheets set defaults once instead of repeating them on every call. matplotlib.rcParams['lines.linewidth'] = 2 (or the equivalent plt.rc('lines', linewidth=2)) changes the default for every subsequent plot in the process; plt.style.use('seaborn-v0_8') (or any named or custom style sheet) swaps in a whole bundle of such defaults at once — colours, fonts, grid visibility — which is how a report with a dozen charts stays visually consistent without a dozen copies of the same keyword arguments.

Figure lifecycle is the sharpest edge in the whole lesson, because it produces a real, measured leak rather than a hypothetical one. Every figure created through plt.figure() or plt.subplots() is retained in a global registry — plt.get_fignums() lists it — until plt.close(fig) or plt.close('all') explicitly removes it. There is no automatic cleanup tied to a Python variable going out of scope; a function that plots in a loop and returns without closing leaks one figure, every call. Measured directly: opening 22 figures in a loop without closing any left plt.get_fignums() reporting all 22 as open, and triggered exactly one real RuntimeWarning, containing the text "More than 20 figures have been opened" — matplotlib’s own defence against exactly this pattern, firing once the count passes its default threshold of 20 (rcParams['figure.max_open_warning']). Closing each figure individually brought plt.get_fignums() back to an empty list. A long-running report job or a training loop that plots a diagnostic chart every epoch without closing it will not crash on epoch 21 — it will simply hold every figure it has ever created in memory, indefinitely, until something notices the process is slow or the machine is out of memory.

An everyday analogy

Think of a Figure as a picture frame you bought, and an Axes as a canvas you mounted inside it — you can mount several canvases in one frame (a subplot grid), and each canvas holds its own painting, its own caption card, its own frame-within-the-frame for its title.

The pyplot state machine is like handing instructions to an assistant who always works on “whatever canvas is on the easel right now.” Say “paint a red line” and they paint it on the easel canvas. Say “paint a blue line” five minutes later, with nothing said about switching canvases, and they paint it on the same easel canvas, because nobody told them to put up a new one — from their point of view, that is exactly what you asked for. The object API is like handing a signed, labelled instruction directly to the specific canvas you want painted: there is no “easel” to be ambiguous about, because the instruction names its target.

savefig is like photographing the framed picture: figsize is the picture’s physical size, dpi is how many pixels the camera captures per inch of picture, and multiplying them tells you exactly how big the photo file will be — unless you ask the photographer to crop the photo to just the painted area (bbox_inches='tight'), in which case the final size depends on how much blank frame there was to crop away. A PNG is that photograph: sharp at the size it was taken, blurry if you blow it up further. An SVG is more like the original painting’s inventory record — a description precise enough to repaint the exact same picture at any size, with the caption card’s text still legible as text, not as a smudge of paint.

Examples in practice

Diagram: animated flow, two panels. Top panel shows three pyplot commands -- plt.plot, plt.xlabel, plt.title -- each sending an arrow toward a single box labelled "current figure", which is drawn twice in sequence to show a second call landing on the same box as the first. Bottom panel shows the same three kinds of commands as ax.plot, ax.set_xlabel, ax.set_title, each sending an arrow directly to a specific named Axes box, with a second call shown routing to a second, separate Axes box

Every example below was captured from a real, headless matplotlib 3.11.1 run — matplotlib.use("Agg") set before pyplot is imported, no window ever opened, and every figure closed with plt.close() when the example is done with it.

The two-APIs bug, made and unmade. The pyplot version:

def draw_pyplot(x, y, label):
    plt.plot(x, y, label=label)
    plt.xlabel("x")
    plt.title("drawn with the pyplot state machine")
    plt.legend()

draw_pyplot([0, 1, 2, 3], [0, 1, 4, 9], "run A")
draw_pyplot([0, 1, 2, 3], [9, 4, 1, 0], "run B")
# plt.get_fignums() -> [1]   -- one figure
# len(plt.gcf().axes[0].lines) -> 2   -- both lines on it

The object version:

def draw_object(x, y, label):
    fig, ax = plt.subplots()
    ax.plot(x, y, label=label)
    ax.set_xlabel("x")
    ax.set_title("drawn with the object API")
    ax.legend()
    return fig, ax

fig_a, ax_a = draw_object([0, 1, 2, 3], [0, 1, 4, 9], "run A")
fig_b, ax_b = draw_object([0, 1, 2, 3], [9, 4, 1, 0], "run B")
# plt.get_fignums() -> [1, 2]   -- two figures
# len(ax_a.lines) -> 1, len(ax_b.lines) -> 1

Pixel arithmetic, checked against the file itself.

fig, ax = plt.subplots(figsize=(6, 4))
ax.plot([0, 1, 2], [0, 1, 0])
fig.savefig("a.png", dpi=100)   # -> 600 x 400, read from a.png's own header
fig.savefig("b.png", dpi=200)   # -> 1200 x 800

Testing a chart without ever looking at it. This is the day’s most practical habit, and it generalizes to every chart you will ever build in this course: assert on the object graph, never on pixels.

assert ax.get_xlabel() == "depth (m)"
assert ax.get_ylim() == (-5, 5)
assert len(ax.lines) == 2
assert ax.lines[0].get_xydata().tolist() == [[0.0, 2.0], [1.5, -1.0]]
assert ax.get_yscale() == "log"

No golden image, no pixel-diff tolerance, no test that breaks because a font changed between matplotlib versions. Each assertion checks exactly the claim a person reading the chart would care about, and each one comes back instantly and deterministically.

Implications: security, privacy, performance, scalability, and cost

Security. matplotlib reads and writes files (savefig, and rarely, reading an image with imread) and evaluates no user-supplied code by default. The one sharp edge: a MATPLOTLIBRC config file or a style sheet loaded from an untrusted path is just a text file matplotlib parses, not a security boundary — do not load style sheets from user-supplied paths in a service that renders charts on someone else’s behalf.

Privacy. Charts embed exactly the data you plot, and vector formats embed it more legibly than raster ones. An SVG or PDF chart’s axis labels, tick labels, and any ax.text annotation are literal searchable text in the saved file — which is a feature for a report you intend readers to copy numbers out of, and a real leak if the same chart is generated from data that should not be that easy to extract (a screenshot of a dashboard containing account numbers, say, saved as SVG rather than PNG). Know which property you are relying on before you pick the format.

Performance. Headless rendering (the Agg backend, set via matplotlib.use("Agg") before importing pyplot, or the MPLBACKEND=Agg environment variable) is what makes matplotlib usable in a script, a test suite, or a server process with no display attached — every chart in this course’s labs is generated this way. Rendering itself is fast for anything with a few thousand points or fewer; it slows down noticeably past tens of thousands of points per Axes, at which point downsampling before plotting (or a GPU-accelerated alternative outside this lesson’s scope) matters more than any matplotlib-level tuning.

Scalability. The figure-lifecycle leak measured earlier in this lesson is the scalability failure mode that actually shows up in practice: a long-running process (a monitoring dashboard regenerating charts on a schedule, a training script logging a diagnostic plot every epoch) that never calls plt.close() accumulates one Figure object per chart, forever, until memory pressure or the 20-figure warning is the first sign anyone notices. The fix costs one line — plt.close(fig) right after savefig, or a with block pattern that guarantees it — and is easy to forget precisely because the first hundred iterations look fine.

Cost. matplotlib itself has no cost of any kind — free, open source, BSD-compatible licence, no tier, no account. The cost this lesson’s implications are really about is engineering time: the two-APIs bug and the figure leak are both silent, both easy to introduce, and both cheap to prevent once you know to look for them, which is the entire value proposition of spending a day on “fundamentals” that might otherwise look too basic to deserve one.

Alternatives: free, open source, and commercial

LibraryWhen to choose itHow it is calledOne snippetFree vs paid
matplotlib (ran)The default for this course: full control over every visual element, the substrate other libraries build on, works everywhere from a script to a paper figure.fig, ax = plt.subplots(); ax.plot(x, y)fig.savefig("out.svg")Fully free and open source (BSD-compatible licence); no paid tier of any kind.
seaborn (installed, ran — Day 129 covers it)Statistical plots — distributions, categorical comparisons, regression fits — with far less code than the matplotlib equivalent, and sensible defaults for colour and style out of the box.sns.lineplot(data=df, x="day", y="value", hue="group", ax=ax) — takes and returns a matplotlib Axessns.histplot(data=df, x="value", ax=ax)Fully free and open source (BSD-3-Clause); no paid tier.
plotnine (docs only, not run here)A grammar-of-graphics builder for Python, porting R’s ggplot2 model: charts are assembled by adding layers rather than calling imperative draw commands, which some analysts find clearer for faceted, multi-layer statistical charts.(ggplot(df, aes(x="day", y="value")) + geom_line() + facet_wrap("group"))same expression, + theme_minimal() addedFully free and open source (BSD-2-Clause); no paid tier.
plotly (docs only, not run here)Interactive, browser-rendered charts — hover tooltips, zoom, pan — for a notebook, a web app, or a Dash dashboard, where a static image is not enough.px.line(df, x="day", y="value", color="group") (the plotly.express high-level interface)fig.write_html("out.html")Core library and Dash open-source framework are free (MIT-licensed); static-image export needs the separate kaleido package; Dash Enterprise (deployment, authentication, scaling for organizations) is a paid product layered on top of the free framework.

matplotlib, seaborn and plotnine are all free and open source with no paid tier whatsoever. plotly’s charting library and open-source Dash framework are free; the only cost anywhere in this ecosystem is Plotly’s enterprise deployment product, which this lesson’s labs never touch. This lesson ran matplotlib and seaborn for real; plotnine and plotly are described from their public documentation only, and no output attributed to either is reproduced anywhere in this lesson or its lab.

ConceptWhat it actually isHow it differs from matplotlib fundamentals
Chart type selection (Day 127)Choosing which shape of chart honestly represents a given relationship or comparison.A decision made before any matplotlib code is written; today’s lesson is about how to build whichever chart Day 127 says you need, correctly.
seaborn (Day 129)A statistical-plotting layer built on top of matplotlib’s own Axes objects.Every sns.* function still returns (or accepts) a matplotlib Axes — today’s object model is what seaborn’s convenience sits on top of, not a separate system.
Chart honesty (Day 132)Choosing scales, baselines and framing that do not mislead — not truncating a bar chart’s y-axis, not hiding an inconvenient point.Today’s set_yscale('log') silently dropping a zero-valued point is a mechanism Day 132’s honesty concerns can be caused by; today explains the mechanism, Day 132 covers the judgment calls around when a log scale is even the right choice.
Plotting versus computingmatplotlib turns arrays of numbers you already have into a rendered chart. It does not compute a mean, a regression, or a confidence interval for you.pandas and NumPy (Weeks 15-18) or scipy compute the numbers; matplotlib (and seaborn, on top of it) draw them. Confusing “the chart looks wrong” with “the underlying statistic is wrong” is a common and costly mix-up.
Testing a chartAsserting on the Figure/Axes/Artist object graph — labels, limits, line data, legend text, figure counts.Different in kind from testing most other code: the object graph is the chart, so there is no separate “rendering correctness” question the way there is for, say, a PDF layout engine.

When to use it — and when not to

Use matplotlib, through the object API, for essentially every chart this course produces: exploratory data analysis, a report figure that will be saved and shared, a diagnostic plot inside a training loop, a static architecture diagram. It is the right default because it is free, has no external dependency beyond itself, runs headless in a script or CI job exactly the way this lesson’s lab does, and its output is precise and reproducible down to the pixel.

Reach for something layered on top of it — seaborn — when the chart is fundamentally statistical (a distribution, a categorical comparison, a regression fit) and the matplotlib-only version of the same chart would be a dozen lines of manual computation before a single ax.plot() call. Reach outside matplotlib’s own family entirely — plotly, or a JavaScript-based tool for a web page — when the chart genuinely needs interactivity a static image cannot provide: hover tooltips over thousands of points, a zoomable time series a reader will explore rather than just read.

Do not reach for matplotlib’s pyplot state machineplt.plot, plt.xlabel — inside any function that might be called more than once, or inside any codebase larger than a single notebook cell meant to be read top to bottom exactly once. That is the one piece of “when not to use it” this lesson insists on, because it is the one failure mode that produces a chart which looks fine and is wrong.

Knowledge check

  1. Two calls to a plt.plot-based helper function, with no plt.figure() call in between, produce how many figures — and why?
  2. What two savefig arguments determine a saved PNG’s exact pixel dimensions, and what breaks that exact prediction?
  3. Why does plt.subplots(2, 3)[1] (the returned Axes array) have shape (2, 3) rather than being a flat list of six Axes?
  4. What does ax.set_yscale('log') actually do to a data point with value zero — measured, not assumed?
  5. In what order do a legend’s entries appear, and what determines it?
  6. What is the concrete, file-level difference between what a saved SVG contains and what a saved PNG contains?
  7. What keeps a figure “alive” in matplotlib’s global registry, and what removes it?
  8. Why is asserting on ax.get_xlabel() or ax.lines[0].get_xydata() a more robust way to test a chart than comparing rendered pixel images?

Hands-on exercise

Work through starter/00_brief.md in this lesson’s lab, labs/sections/math-statistics-and-data/day-128-matplotlib-fundamentals/. Nine exercises, each a small function in starter/plotting.py: the two APIs and their differing figure counts, an exact data round-trip through a Line2D artist, savefig’s pixel arithmetic checked against a real PNG’s own header bytes, labels and an explicit set_ylim that overrides autoscaling, an independent (2, 3) grid of subplots, a log scale’s silent treatment of a zero-valued point, the label-then-legend pattern, the figure-lifecycle leak and matplotlib’s own warning past 20 open figures, and the searchable-text-versus-opaque-pixels difference between SVG and PNG. Every exercise runs headless and writes only to a temporary directory that cleans itself up.

cd labs/sections/math-statistics-and-data/day-128-matplotlib-fundamentals
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
.venv/bin/pytest starter -q

Expected output

On an untouched checkout:

1 passed, 13 skipped

Once every function in starter/plotting.py is written correctly:

14 passed

The reference suite in examples/ — read after attempting the starter — reports:

19 passed

and the full harness ends with:

34 checks, 0 failure(s).

Validate your work

.venv/bin/pytest starter -q -p no:cacheprovider
bash tests/run_tests.sh; echo "exit=$?"

A skip means a function has not been attempted yet (it still raises NotImplementedError). A failure means the function runs but returns the wrong thing, and the failure message shows both your value and the expected one. run_tests.sh finishing with exit=0 and N checks, 0 failure(s). is the complete signal that every exercise, both suites, and the disk-cleanliness check all agree the lab is done.

Troubleshooting

See troubleshooting.md in the lab directory for the full list. The two most common snags: running a reference script from the lab’s root directory instead of from inside examples/ (it imports plotting relative to itself, so it must be run from beside it), and forgetting that save_at_size_and_dpi must not pass bbox_inches='tight' — that argument is exactly what breaks the exact pixel-arithmetic prediction exercise 3 checks for.

Common mistakes

Practice assignment

Take any dataset you have used earlier in this course (a CSV from Week 18, or invented data of your own) and build a two-panel figure with fig, ax = plt.subplots(1, 2, figsize=(10, 4)): the left panel a line chart with a proper x-label, y-label and title; the right panel the same data’s cumulative sum, on a shared x-axis (sharex=True). Save the result twice — once as PNG at 150 dpi, once as SVG — and write a short script that asserts, without opening either file visually: both Axes have non-empty titles, the left Axes has exactly one line whose data round-trips exactly, the right Axes’ y-values are monotonically non-decreasing (a property a cumulative sum should have), and the SVG file contains both titles as searchable text while the PNG’s byte content does not.

Extension challenge

Build a small “figure budget” context manager: a with figure_budget(): block that records plt.get_fignums() on entry, and on exit, raises an assertion error naming exactly which figure numbers were left open if the count on exit exceeds the count on entry. Wrap it around a deliberately leaky function (one that calls plt.subplots() in a loop without closing) and confirm it catches the leak; then wrap it around a correctly written function and confirm it passes silently. This is the same idea production monitoring tools use for other kinds of resource leaks — database connections, open file handles — applied to the one resource this lesson showed you matplotlib will happily leak without complaint until you are 21 figures in.

Quiz

Q1. A helper function calls plt.plot(x, y) and plt.xlabel("x") to draw a chart. It is called twice in a row, with different data, and nothing calls plt.figure() in between. What does plt.get_fignums() report afterward?

  1. A list of two figure numbers, one per call
  2. A list with exactly one figure number, holding both lines
  3. An empty list, since neither call created a named figure
  4. An error, because plt.plot cannot be called twice without plt.show() in between
Show answer

Answer: B. A list with exactly one figure number, holding both lines

Every plt.* call draws into whichever figure is currently "current" (plt.gcf()). Without an intervening plt.figure() or plt.subplots(), the second call finds the same current figure the first call left behind, and both lines land on it. This lesson measured exactly this: plt.get_fignums() == [1] with two lines on that one figure, which is the entire motivation for the object API.

Q2. What structurally prevents fig, ax = plt.subplots() from ever drawing into a different figure than the one just created, in a way the pyplot state machine cannot guarantee?

  1. plt.subplots() automatically calls plt.close() on any other open figures
  2. Every following instruction is a method call on the specific ax object returned, which has no notion of "current" to get confused about
  3. The object API only allows one figure to exist per Python process
  4. ax.plot() checks a global lock before drawing to prevent concurrent figures
Show answer

Answer: B. Every following instruction is a method call on the specific ax object returned, which has no notion of "current" to get confused about

The object API names its target explicitly: ax.plot(), ax.set_xlabel() and so on all operate on the specific Axes object in hand. There is no "currently active" figure or axes for a bug to route through by mistake -- the only way to draw into the wrong Axes is to be handed the wrong ax variable, which is a different and much rarer mistake than forgetting to call plt.figure().

Q3. A figure is created with figsize=(6, 4) and saved with fig.savefig(path, dpi=100) and no bbox_inches argument. What are the saved PNG's pixel dimensions?

  1. 600 x 400
  2. 6 x 4
  3. 100 x 100
  4. It depends on how much whitespace surrounds the plotted data
Show answer

Answer: A. 600 x 400

Pixel dimensions are figsize (in inches) times dpi, exactly, when the default bounding box is used: 6 inches x 100 dpi = 600 pixels wide, 4 inches x 100 dpi = 400 pixels tall. This lesson verified it by reading the saved PNG's own IHDR header bytes, not by trusting the arithmetic, and confirmed doubling dpi to 200 doubles both dimensions to exactly 1200 x 800.

Q4. Why does bbox_inches="tight" break the figsize-times-dpi pixel prediction?

  1. It has no effect on the saved file, only on the on-screen preview
  2. It doubles the dpi automatically to compensate for cropping
  3. It crops the saved output to the bounding box of what was actually drawn, so the final size depends on the content and its margins rather than only on figsize and dpi
  4. It only affects vector formats like SVG, never PNG
Show answer

Answer: C. It crops the saved output to the bounding box of what was actually drawn, so the final size depends on the content and its margins rather than only on figsize and dpi

bbox_inches="tight" is often what you want visually -- it trims empty margin around the plotted content -- but that means the saved size is figsize x dpi MINUS whatever got trimmed, which depends on the specific figure's content, labels and layout. Exact pixel arithmetic requires the default, untrimmed bounding box.

Q5. plt.subplots(2, 3) is called, and a label is set on axes[0, 0]. What is axes[0, 1].get_xlabel() afterward?

  1. The same label as axes[0, 0], since they share a row
  2. An empty string, because each Axes in the returned array is independent
  3. A TypeError, since axes cannot be indexed like a 2-D array
  4. The label of whichever Axes was created most recently
Show answer

Answer: B. An empty string, because each Axes in the returned array is independent

plt.subplots(nrows, ncols) returns a genuine (nrows, ncols) numpy array of independent Axes objects. Setting state on one entry -- a label, a title, a plotted line -- has no effect on any other entry. This lesson confirmed it directly: after labelling only axes[0, 0], every other cell in a 2x3 grid reported an empty string for get_xlabel().

Q6. A series [0, 1, 4, 9, 16] is plotted and ax.set_yscale("log") is applied, then the figure is redrawn. What actually happens to the point at (x=0, y=0), measured on matplotlib 3.11.1?

  1. matplotlib raises a ValueError, since log(0) is undefined
  2. The point is silently removed from ax.lines[0].get_xydata()
  3. matplotlib always prints a warning naming the offending zero value
  4. The point remains in the line's stored data, but the rendered y-limits narrow to exclude it, so it draws nothing and no error or warning fires for this mixed-sign case
Show answer

Answer: D. The point remains in the line's stored data, but the rendered y-limits narrow to exclude it, so it draws nothing and no error or warning fires for this mixed-sign case

Measured directly: after set_yscale("log") and a forced redraw, ax.get_ylim() came back as roughly (0.87, 18.4) -- a lower bound strictly above zero -- while ax.lines[0].get_xydata() still contained the original (0, 0) point untouched. matplotlib only emits its "no positive values" warning when EVERY value is non-positive; a mix draws nothing for the zero point and says nothing about it.

Q7. ax.plot(x, y1, label="B") is called, then ax.plot(x, y2, label="A") is called, then ax.legend(). What order do the legend entries appear in?

  1. Alphabetical: "A" then "B"
  2. Plotting order: "B" then "A"
  3. Reverse plotting order: "A" then "B", most recent first
  4. Undefined -- legend order is randomized each render
Show answer

Answer: B. Plotting order: "B" then "A"

A Legend's entries follow the order the labelled artists were created in, not the order of their label strings. This lesson's lab measured exactly this: [t.get_text() for t in legend.get_texts()] equals the labels in plotting order, which is why the label-then-legend pattern calls legend() only after every relevant plot() call.

Q8. A report-generation function calls fig, ax = plt.subplots() inside a loop that runs 25 times, without ever calling plt.close(fig). What happens, measured on matplotlib 3.11.1?

  1. Nothing -- matplotlib automatically closes figures once their variable goes out of scope
  2. The process crashes immediately after the 20th figure
  3. plt.get_fignums() grows to 25 entries, and a RuntimeWarning naming "More than 20 figures" fires once the count passes 20 -- the figures stay open and consume memory until plt.close() is called on each
  4. Each new call to plt.subplots() silently reuses and overwrites the previous figure
Show answer

Answer: C. plt.get_fignums() grows to 25 entries, and a RuntimeWarning naming "More than 20 figures" fires once the count passes 20 -- the figures stay open and consume memory until plt.close() is called on each

Every figure created through pyplot stays in a global registry until explicitly closed -- there is no automatic cleanup tied to a Python variable going out of scope. This lesson triggered the real warning: opening 22 figures without closing any produced exactly one RuntimeWarning containing "More than 20 figures," and plt.get_fignums() reported all 22 as open until plt.close("all") emptied the registry back to zero.

Q9. The same chart is saved once as reading.png and once as reading.svg. Searching each file's own bytes for the axis label text "depth (m)", what is found?

  1. The text is found inside the SVG file and not found anywhere inside the PNG file
  2. The text is found in both files, since both formats embed the original label as metadata
  3. The text is found in neither file -- labels are rendered into un-searchable glyph outlines in both formats
  4. The text is found only in the PNG, since SVG stores text as vector paths rather than characters
Show answer

Answer: A. The text is found inside the SVG file and not found anywhere inside the PNG file

SVG is XML markup -- a text label is written as a literal <text> element, searchable in any text editor. PNG is a fixed grid of pixel colour values -- the string that produced a label is nowhere in the file's bytes, only the pixels its glyphs were rasterized into. This lesson confirmed both directions directly: "depth (m)" was found inside the saved SVG's characters and absent from the saved PNG's bytes, which is the concrete, testable version of "vector output is better for anything that will be zoomed, printed, or edited later."

Glossary

Figure
The whole canvas matplotlib draws on -- the object you save to a file with savefig, and the top of the object model. A Figure holds one or more Axes; it owns nothing about what gets plotted, only where the Axes sit and at what overall size and resolution the canvas gets rendered.
Axes
A single set of x/y (or x/y/z) coordinates inside a Figure, and the object almost everything in this lesson is a method call on. An Axes owns its plotted Artists, its labels, its title, its limits, its ticks and its legend. Despite the name's similarity to "axis," one Axes typically has two axis objects (x and y) belonging to it -- the plural in "Axes" refers to this pairing, not to there being several of them.
Artist
matplotlib's base class for literally everything that gets drawn -- a Line2D from a plot() call, a Text from a label or title, a Rectangle from a bar, a Legend. An Axes' plotted content is a list of Artist objects, which is exactly what makes a chart testable: ax.lines is a list of Line2D artists, and each one's get_xydata() returns the numbers that produced it.
pyplot state machine
The plt.plot / plt.xlabel / plt.title style of calling matplotlib, where every function operates on whichever Figure and Axes are currently "current" (plt.gcf() and plt.gca()) rather than on an object you named yourself. Convenient for a single quick plot in a notebook; the source of the two-APIs bug the moment a drawing routine gets called more than once.
object API
The fig, ax = plt.subplots() style, where every following instruction is a method call on that specific ax. Every example in this course uses this style, because it makes it structurally impossible for one call to silently draw into a different figure than the one you meant.
savefig
The Figure method that writes a chart to a file, with the output format inferred from the extension (or set explicitly via format=). Its output size in pixels is figsize (inches) times dpi, exactly -- unless bbox_inches='tight' trims the canvas to the drawn content afterward, which breaks that exact prediction.
dpi
Dots per inch -- the resolution a Figure gets rendered at when saved to a raster format. Combined with figsize, dpi determines the output's pixel dimensions exactly: a 6x4 inch figure at 100 dpi saves at 600x400 pixels; at 200 dpi, 1200x800.
bbox_inches
A savefig argument controlling how much of the Figure's canvas gets written to the output file. The default writes the whole canvas at its declared figsize; 'tight' instead crops the output to the bounding box of everything actually drawn, which is often what you want visually but breaks the exact figsize-times-dpi pixel arithmetic, since the trimmed size depends on the content.
subplots grid
The array of Axes objects plt.subplots(nrows, ncols) returns when either dimension exceeds 1 -- a genuine 2-D numpy array shaped (nrows, ncols), not a flat list. Each entry is a fully independent Axes: setting a label on axes[0, 0] never touches axes[0, 1].
autoscale
matplotlib's default behaviour of choosing axis limits that fit the plotted data with a small margin, recomputed whenever new data is plotted. An explicit call to set_xlim or set_ylim overrides autoscaling for that axis; autoscaling does not resume unless autoscale() is called again explicitly.
log scale
An axis scale (set with set_yscale('log') or set_xscale('log')) where equal steps represent equal ratios rather than equal differences. It has no representation for zero or negative values -- matplotlib does not raise an error over this, it silently narrows the rendered range to exclude non-positive values, leaving the underlying data untouched but the affected points undrawn.
label-then-legend pattern
The convention of passing label= to every plot call that should appear in the legend, then calling ax.legend() once, after every relevant artist has been created. The legend's entries appear in plotting order, which is why the pattern names it "label-then" -- the labelling happens first, the single legend() call happens last.
figure lifecycle
The fact that every Figure created through pyplot (plt.figure(), plt.subplots()) is retained in a global registry until plt.close(fig) or plt.close('all') removes it -- it does not get garbage-collected just because the variable holding it goes out of scope. A function that plots in a loop and returns without closing leaks one figure per call; matplotlib issues its own RuntimeWarning once more than 20 figures are open at once (rcParams figure.max_open_warning).
raster image
An image format (PNG, JPEG) stored as a fixed grid of pixel colour values. Text and lines in a raster image are rendered into pixels at save time and cannot be searched, selected, or resized without quality loss -- the string that produced a label does not appear anywhere in the file's bytes.
vector image
An image format (SVG, PDF, EPS) stored as a description of shapes, paths and text -- resolution-independent, and, for SVG, literally XML markup you can open in a text editor. A vector chart's axis label appears as a literal <text> element you can search for in the file; the file can be zoomed or printed at any size without pixel artifacts.
rcParams
matplotlib's global dictionary of default settings -- figure size, font size, line width, colour cycle, and hundreds more -- read from matplotlib.rcParams or set in bulk from a named style sheet with plt.style.use(). Setting defaults once through rcParams, rather than repeating the same keyword argument on every plot call across a report, is what keeps a multi-chart document visually consistent.

Sources and further reading


Kept in this browser, no account needed. Your progress page turns the whole record into one link you can bookmark or open on another device.