Visualising Data with Matplotlib: 10 Code-Along Examples

Learn matplotlib by drawing. Ten copy-and-run examples covering the figure and axes model, line styling, labels, subplot grids, twin axes, annotations, bar charts, distributions, and scatter plots.

Learn matplotlib by drawing. Ten copy-and-run examples covering the figure and axes model, line styling, labels, subplot grids, twin axes, annotations, bars, histograms, error bars, box plots, and scatter plots.

The matplotlib article covers the library every other Python plotting tool is built on, and the reason to learn it directly is control: once you understand what a figure is and what an axes is, every chart you will ever build becomes a matter of calling methods on an object rather than hunting for the one function that happens to produce the picture you want. Every figure in this workbook was produced by the code above it, so what you see is what the code makes. This workbook works through the whole range: the two-line skeleton, drawing and styling lines, labelling honestly, grids of subplots and the shared axis that keeps them honest, twin axes, annotations, bar and stacked bar charts, distributions three ways, and scatter plots with a third variable in colour. The idea that ties it together arrives in the last example.

Every example uses this data, which you can paste once:

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
london = [412, 398, 455, 470, 512, 548, 590, 604, 561, 523, 588, 671]
paris = [301, 322, 318, 349, 377, 402, 433, 451, 419, 396, 428, 495]
berlin = [255, 268, 280, 296, 311, 340, 366, 372, 358, 333, 361, 402]

1. The figure and the axes

Every matplotlib chart starts with the same line, and understanding what it hands back is most of the learning curve.

fig, ax = plt.subplots(figsize=(6, 3))
ax.plot(months, london)
plt.show()

Two objects come back from that call and they do different jobs. fig is the whole canvas, the sheet of paper, and it owns things that belong to the image as a whole such as its size, its resolution and the file it gets saved to. ax is the rectangular drawing area on that paper, and it owns everything about this particular chart: the data, the axis labels, the title, the tick marks. You draw by calling methods on ax, which is why almost every line in this workbook starts with ax., and the payoff for that separation arrives in Example 5 when one figure holds four axes. The figsize argument is in inches and is worth setting deliberately rather than accepting the default, because it decides the aspect ratio and therefore how steep every line in your chart appears.

2. Two lines on one axes

Calling plot again on the same axes adds to the chart rather than replacing it, which is the whole mechanism for building up a figure.

fig, ax = plt.subplots(figsize=(6, 3))
ax.plot(months, london)
ax.plot(months, paris)
plt.show()

Two lines, two colours, and you asked for neither colour. Matplotlib cycles through a default palette so that successive series are distinguishable without you naming anything, which is useful for quick exploration and not good enough for anything you will show another person, because the colours carry no meaning and will change if you add or reorder a series. The drawing order is also the stacking order, so the second call is painted on top of the first, which matters when lines overlap or when one series is a filled area. Nothing here is labelled yet, so this chart tells a reader that something went up, and nothing else, which Example 4 fixes.

3. Controlling how a line looks

Three keyword arguments cover almost every styling decision you need to make about a line.

fig, ax = plt.subplots(figsize=(6, 3))
ax.plot(months, london, color="#1e2331", marker="o", linestyle="-", label="London")
ax.plot(months, paris, color="#6f7580", marker="s", linestyle="--", label="Paris")
ax.plot(months, berlin, color="#3f88c5", marker="v", linestyle="None", label="Berlin")
ax.legend()
plt.show()

Three series, three deliberate looks. color takes a named colour, a single-letter shortcut or a hex string, and hex is what you want for anything that has to match a brand. marker puts a symbol at each real data point, which is more useful than it looks because it shows the reader where you have measurements and where the line is just interpolating between them. linestyle controls the connecting line, and the third series shows the trick worth remembering: linestyle="None" as a stringremoves the line entirely and leaves the markers, giving you a scatter effect from ax.plot. Note that Python’s None will not work there, because matplotlib reads that as “use the default”. The label arguments do nothing on their own and exist to feed ax.legend().

4. Labelling, and why it goes on the axes

A chart without labels is a puzzle. Three calls solve it, and all three are called on the axes rather than on plt.

fig, ax = plt.subplots(figsize=(6, 3.2))
ax.plot(months, london, marker="o", color="#1e2331")
ax.plot(months, paris, marker="s", color="#3f88c5")
ax.set_xlabel("Month, 2026")
ax.set_ylabel("Revenue (GBP thousands)")
ax.set_title("Monthly revenue: London against Paris")
ax.legend(["London", "Paris"])
plt.show()

The same picture as Example 2, now readable by someone who was not in the room when you made it. Units are the part people skip and the part that matters most, because “Revenue” alone leaves a reader guessing between pounds, thousands and millions, and a chart that has to be explained out loud has failed at the only job it had. Calling these on ax rather than on plt looks like a stylistic preference and becomes a necessity in the next example, since plt acts on whichever axes matplotlib currently considers active and ax names the one you mean. When category labels along the bottom are long enough to collide, ax.tick_params(axis="x", labelrotation=45) is the fix.

5. Grids of charts, and the axis that keeps them honest

Passing rows and columns to subplots gives you an array of axes, and one argument decides whether the comparison between them is fair.

fig, ax = plt.subplots(2, 2, figsize=(7, 4.2))
ax[0, 0].plot(months, london, color="#1e2331")
ax[0, 1].plot(months, paris, color="#3f88c5")
ax[1, 0].plot(months, berlin, color="#6f7580")
ax[1, 1].plot(months, np.array(london) + np.array(paris) + np.array(berlin), color="#a94442")
fig.tight_layout()
plt.show()
fig, ax = plt.subplots(2, 1, sharey=True, figsize=(6, 3.6))
ax[0].plot(months, london, color="#1e2331")
ax[1].plot(months, berlin, color="#6f7580")

The grid is addressed the way you would expect, ax[0, 0] top left through ax[1, 1] bottom right, and a single row or column collapses to one index. The two stacked charts underneath are the important pair. With sharey=True both panels use one scale, and London is visibly the larger business. Without it, matplotlib fits each panel to its own data, the two lines become nearly identical shapes, and a reader glancing at the pair would conclude the cities are comparable when one is two-thirds larger than the other. Nothing was falsified to produce that second chart; it is the default. That is exactly why it is worth knowing, and why shared axes should be your habit whenever panels invite comparison.

6. Twin axes for two scales

When two series share an x-axis but live on different scales, twinx gives the second one its own y-axis on the right.

fig, ax = plt.subplots(figsize=(6.4, 3.2))
ax.plot(energy.index, energy["emissions"], color="#1e2331")
ax.set_ylabel("Emissions (index)", color="#1e2331")
ax.tick_params(axis="y", labelcolor="#1e2331")
ax2 = ax.twinx()
ax2.plot(energy.index, energy["avg_temp"], color="#a94442")
ax2.set_ylabel("Avg temperature (C)", color="#a94442")
ax2.tick_params(axis="y", labelcolor="#a94442")
plt.show()

Two series that would have been unreadable together, since an emissions index between 0.7 and 1.1 plotted against temperatures from 1 to 17 would have flattened the first into a horizontal line. Colour-coding both the line and its axis label is not decoration here, it is the only thing telling the reader which scale belongs to which series, and a twin-axis chart without it is genuinely ambiguous. Use this sparingly and with a clear conscience, because you now control two independent scales and small changes to either can make two unrelated series appear to move together. The correlation between these two series is 0.36, which is weak, and a differently scaled version of this chart could be made to imply much more than that.

7. Annotating the point that matters

An annotation puts text next to a specific data point and draws an arrow to it, which is how you make a chart argue for something.

peak_i = int(np.argmax(energy["avg_temp"]))
fig, ax = plt.subplots(figsize=(6.4, 3.2))
ax.plot(energy.index, energy["avg_temp"], color="#3f88c5")
ax.annotate("Warmest month on record",
xy = (energy.index[peak_i], energy["avg_temp"].iloc[peak_i]),
xytext = (energy.index[3], energy["avg_temp"].max() - 6),
arrowprops = {"arrowstyle": "->", "color": "#6f7580"})
ax.set_ylabel("Avg temperature (C)")
plt.show()

Two coordinates doing two jobs. xy is the data point the arrow points at, in data coordinates, so it moves correctly if the axis limits change. xytext is where the text sits, chosen for empty space rather than for meaning, and arrowprops is what connects them; leave xytext out and the label lands directly on the point with no arrow, which is fine for a short tag and cramped for a sentence. Computing the position rather than typing it, as argmax does here, is the habit worth forming, because a hard-coded coordinate is correct exactly once and silently wrong the next time the data updates.

8. Bars and stacked bars

Bar charts compare amounts across categories, and stacking is a matter of telling each layer where to start.

fig, ax = plt.subplots(1, 2, figsize=(8, 3.2))
ax[0].bar(channels, tier1, color="#1e2331")
ax[0].set_ylabel("Conversions")
ax[1].bar(channels, tier1, label="Tier 1", color="#1e2331")
ax[1].bar(channels, tier2, bottom=tier1, label="Tier 2", color="#3f88c5")
ax[1].bar(channels, tier3, bottom=tier1 + tier2, label="Tier 3", color="#c9ced8")
ax[1].legend(fontsize=8)
for a in ax:
a.tick_params(axis="x", labelrotation=45, labelsize=8)
fig.tight_layout()
plt.show()

The bottom argument is the entire stacking mechanism: each layer starts where the ones below it end, so the second call passes the first series and the third passes the sum of the first two, which is why the arrays are NumPy arrays rather than lists, since tier1 + tier2 on plain lists would concatenate rather than add. Two judgement calls come with stacked bars. The bottom layer is the only one whose length is easy to compare across categories, because every layer above starts from a different baseline, so put the series you most want compared at the bottom. And if the reader’s question is about composition rather than total, a percentage stack or a grouped bar chart usually answers it better.

9. Three ways to show a distribution

Histograms, error bars and box plots answer the same question at different levels of detail.

fig, ax = plt.subplots(1, 3, figsize=(9.5, 3))
ax[0].hist(sprint, bins=12, histtype="step", label="Sprinters", color="#1e2331")
ax[0].hist(endure, bins=12, histtype="step", label="Endurance", color="#a94442")
ax[0].legend(fontsize=8)
ax[1].bar("Sprinters", sprint.mean(), yerr=sprint.std(), color="#1e2331", capsize=5)
ax[1].bar("Endurance", endure.mean(), yerr=endure.std(), color="#a94442", capsize=5)
ax[2].boxplot([sprint, endure], tick_labels=["Sprinters", "Endurance"])
fig.tight_layout()
plt.show()
sprint mean 990 sd 87 | endure mean 763 sd 132

Three views, increasing in compression from left to right. histtype="step" is the detail that makes the first panel work, drawing outlines instead of filled rectangles so two overlapping distributions both stay legible, where filled bars would have turned into mud. The middle panel is the one to be careful with: two bars showing means alone would suggest sprinters simply produce more power, and the error bars show that the endurance group is far more variable, with the two distributions overlapping considerably. That is the argument for never plotting a mean without its spread. The box plot on the right compresses everything into one shape per group, the box covering the middle half of the data, the line inside it the median, the whiskers reaching to 1.5 interquartile ranges and anything beyond drawn as a dot, which is why the sprinters show one flagged outlier at the top.

10. Scatter plots, a third variable, and the model

Scatter plots show a relationship between two variables, and the c argument smuggles in a third.

fig, ax = plt.subplots(figsize=(6, 3.4))
sc = ax.scatter(energy["emissions"], energy["avg_temp"],
c=range(len(energy)), cmap="viridis", s=45)
ax.set_xlabel("Emissions (index)")
ax.set_ylabel("Avg temperature (C)")
cb = fig.colorbar(sc, ax=ax)
cb.set_label("Months elapsed")
plt.show()

Use scatter rather than plot whenever there is no meaningful order between the points, because a connecting line implies a sequence and readers will believe it. Colour encodes time here, which turns a static cloud into something you can read a direction from, and the colorbar is what makes that colour mean anything; without it you have decoration. s controls marker size and can also take an array, giving a fourth variable, though two encodings on one chart is usually the practical limit before it stops being readable.

Everything in this workbook combines into a chart that is finished rather than merely drawn:

fig, ax = plt.subplots(figsize=(7, 3.6))
ax.plot(months, london, color="#1e2331", marker="o", linewidth=2, label="London")
ax.plot(months, paris, color="#3f88c5", marker="s", linewidth=2, label="Paris")
ax.plot(months, berlin, color="#c9ced8", marker="^", linewidth=2, label="Berlin")
ax.annotate("December push", xy=(11, london[-1]), xytext=(7.2, 630),
arrowprops={"arrowstyle": "->", "color": "#6f7580"}, fontsize=9)
ax.set_xlabel("Month, 2026")
ax.set_ylabel("Revenue (GBP thousands)")
ax.set_title("Monthly revenue by city")
ax.legend(frameon=False)
ax.spines[["top", "right"]].set_visible(False)
ax.grid(axis="y", alpha=0.3)
plt.show()

The model that ties the ten together is that matplotlib is an object you decorate, not a function you call, and every chart is built in the same four passes. First you make the canvas and decide its shape, which is plt.subplots and the only place figure-level decisions live. Second you draw data onto an axes, which is plotbarhistscatter or boxplot, and every one of them can be called repeatedly to layer series. Third you tell the reader what they are looking at, which is the labels, the title, the legend and the units, and it is the pass most often skipped and the one that decides whether the chart works. Fourth you remove what does not help and emphasise what does, which is the annotation, the hidden spines and the faint gridline in the chart above. Thinking in those four passes is also how you debug a chart that looks wrong, because the fault is almost always in the pass you skipped rather than in the plotting call you have been rereading.

Work through these and you have the article in practice: the figure-and-axes split and why methods hang off ax; layered series and the default colour cycle you should not ship; colormarker and the "None" linestyle; labels with units; subplot grids and the sharey that stops two different series looking alike; twin axes and their honesty problem; annotations positioned by computation rather than by hand; stacked bars built on bottom; three compressions of a distribution and why a mean needs its spread; and a scatter plot carrying a third variable in colour. The habit that follows is a small one: before saving any chart, read it as though you had never seen the data, and if a question forms that the picture cannot answer, that question is what the next label, legend or annotation is for.

Thanks for reading, Andrei.

View Comments (2)

Leave a Reply

Subscribe to My Newsletter

Subscribe to my email newsletter to get the latest posts delivered right to your email. Pure inspiration, zero spam.

Discover more from Discuss Data Science, Machine Learning and Analytics

Subscribe now to keep reading and get access to the full archive.

Continue reading