Visualisation with Matplotlib (and a Touch of Seaborn)
1 · The lesson
readA chart is not decoration. It turns a thousand rows into a shape that a reader can understand in a second. A good chart can settle a question that would otherwise take hours of discussion. A bad chart can mislead people.
You've seen the basics in ds-visualization. This lesson goes deeper into the figure/axes model that matplotlib code is built on, the six chart types worth knowing well, layouts with several panels, styling for publication, and when seaborn is the better choice. It ends with the rules for not misleading your readers, by accident or on purpose.
1. The Figure/Axes Mental Model
Matplotlib has two APIs. The pyplot state machine (plt.plot(), plt.title(), plt.show()) is what tutorials use, because a chart takes only three lines. The object-oriented API (fig, ax = plt.subplots()) is what real projects use, because it still works well when the code grows.
import matplotlib.pyplot as plt import numpy as np # The state-machine way works, but it acts on "the current axes", whichever that is plt.plot([1, 2, 3], [1, 4, 9]) plt.title("squares") plt.show() # The OO (object-oriented) way: you keep the figure and axes in variables fig, ax = plt.subplots(figsize=(6, 4)) ax.plot([1, 2, 3], [1, 4, 9]) ax.set_title("squares") ax.set_xlabel("x") ax.set_ylabel("y") plt.show() # Output: a single-panel line chart, 6x4 inches, with axis labels.
The objects:
Figure: the whole image (the canvas). One per file you save.Axes: a single plot panel inside the figure. A figure can hold many. Don't confuse it withaxis(singular: the x-axis or y-axis of an Axes).Axis: the x or y line of an Axes, with ticks, labels, and a scale.
Every method on the OO API targets a specific Axes. ax.set_title(...), ax.plot(...), ax.legend(...). Naming the Axes every time helps as soon as you need two panels, put a chart inside a function, or style the chart from a script.
Rule for any chart you will keep: call fig, ax = plt.subplots() first, then work on ax. Don't call bare plt.something() after that, because later it becomes unclear which panel it changes.
2. The Six Chart Types Worth Knowing
Most analysis questions can be answered with one of these six. Choose by what you want to show, not by what looks nice.
| Chart | Question it answers | Example |
|---|---|---|
| Line | How does Y change over a continuous X? | Stock price over time |
| Bar | How do Y values compare across discrete categories? | Revenue by region |
| Histogram | What's the distribution of Y? | Shape of customer ages |
| Scatter | Does Y depend on X? (relationship, outliers) | Height vs weight |
| Box | Quartiles, spread, and outliers per group | Salary by department |
| Heatmap | Pattern in a 2-D matrix of values | Correlation matrix, confusion matrix |
import matplotlib.pyplot as plt import numpy as np rng = np.random.default_rng(0) # 1. Line: trends over a continuous x x = np.linspace(0, 10, 100) fig, ax = plt.subplots() ax.plot(x, np.sin(x), label="sin") ax.plot(x, np.cos(x), label="cos", linestyle="--") ax.legend() ax.set_title("Trig over [0, 10]") plt.show() # Output: two curves on one axis (sin solid, cos dashed), legend where it hides the least data. # 2. Bar: compare categories categories = ["EU", "US", "APAC", "LATAM"] revenue = [120, 250, 180, 90] fig, ax = plt.subplots() ax.bar(categories, revenue) ax.set_ylabel("Revenue (M$)") ax.set_title("Q1 Revenue by Region") plt.show() # Output: four vertical bars, EU/US/APAC/LATAM left to right. # 3. Histogram: the shape of a distribution data = rng.standard_normal(1000) fig, ax = plt.subplots() ax.hist(data, bins=30, edgecolor="black") ax.set_xlabel("value") ax.set_ylabel("frequency") ax.set_title("N(0, 1) sample") plt.show() # Output: bell-shaped histogram with 30 bins, centred near 0. # 4. Scatter: the relationship between two continuous variables x = rng.standard_normal(200) y = 2 * x + rng.standard_normal(200) fig, ax = plt.subplots() ax.scatter(x, y, alpha=0.5, s=20) ax.set_xlabel("x") ax.set_ylabel("y") ax.set_title("y vs x (with noise)") plt.show() # Output: cloud of points along a positive diagonal, alpha=0.5 makes overlap visible. # 5. Boxplot: quartiles and outliers, one box per group groups = [rng.standard_normal(100), rng.standard_normal(100) + 1, rng.standard_normal(100) - 0.5] fig, ax = plt.subplots() ax.boxplot(groups) ax.set_xticklabels(["A", "B", "C"]) # works on every matplotlib version ax.set_ylabel("value") ax.set_title("Distribution per group") plt.show() # Output: three boxes side by side. Each shows the median line, IQR box, whiskers and outlier dots. # 6. Heatmap: a 2-D matrix matrix = rng.standard_normal((10, 10)) fig, ax = plt.subplots() im = ax.imshow(matrix, cmap="viridis") fig.colorbar(im, ax=ax) ax.set_title("Random 10x10") plt.show() # Output: a 10x10 coloured grid from dark purple (low) to yellow (high), colourbar on the right.
Avoid pie charts. They are nearly always worse than a bar chart for the same data, because people compare lengths more accurately than angles.
3. Styling: Title, Labels, Legend, Ticks, Limits
Every chart that you share with other people needs at least a title, axis labels with units, and a legend if there is more than one series.
import matplotlib.pyplot as plt import numpy as np x = np.arange(2020, 2026) us = [100, 120, 135, 150, 165, 180] eu = [80, 90, 105, 115, 130, 140] fig, ax = plt.subplots(figsize=(7, 4)) ax.plot(x, us, marker="o", label="US", color="#1f77b4", linewidth=2) ax.plot(x, eu, marker="s", label="EU", color="#ff7f0e", linewidth=2) ax.set_title("Annual Revenue by Region", fontsize=14, fontweight="bold") ax.set_xlabel("Year") ax.set_ylabel("Revenue (M USD)") ax.set_ylim(0, 200) # fixed range, so auto-scaling cannot exaggerate changes ax.set_xticks(x) # exactly the years we plotted ax.grid(True, axis="y", linestyle="--", alpha=0.4) ax.legend(loc="upper left", frameon=False) # Annotate a specific event ax.annotate("Acquisition", xy=(2023, 150), xytext=(2021.5, 175), arrowprops=dict(arrowstyle="->", color="grey")) plt.show() # Output: a two-line chart with US (blue, circles) and EU (orange, squares), # bold title, gridded y-axis, "Acquisition" callout pointing at the 2023 US data point.
Tick density matters more than people think. Automatic ticks on a year axis often show values like 2020.5 and 2021.5, which mean nothing. Set them explicitly with ax.set_xticks(...). The same applies to log scales (ax.set_yscale("log")), percent formatters, and date formatters.
4. Multiple Subplots
plt.subplots(rows, cols) returns one Figure and an Axes array. Loop over axes.flat to keep the code simple, whatever the grid shape.
import matplotlib.pyplot as plt import numpy as np rng = np.random.default_rng(0) fig, axes = plt.subplots(2, 3, figsize=(12, 6)) titles = ["A", "B", "C", "D", "E", "F"] for ax, title in zip(axes.flat, titles): ax.hist(rng.standard_normal(1000), bins=20) ax.set_title(title) ax.set_xlim(-4, 4) # shared scale for comparison fig.suptitle("Six samples from N(0, 1)", fontsize=14) fig.tight_layout() plt.show() # Output: a 2x3 grid of histograms, each titled A-F, all sharing the same x-axis range, # with a figure-level title above the grid.
fig.tight_layout() prevents labels from colliding. sharex=True, sharey=True in plt.subplots(...) gives every panel the same axis limits. You need this when you compare distributions.
For irregular layouts (one large panel, two small ones), use gridspec:
import matplotlib.pyplot as plt from matplotlib.gridspec import GridSpec fig = plt.figure(figsize=(10, 6)) gs = GridSpec(2, 2, figure=fig) big = fig.add_subplot(gs[:, 0]) # left column, both rows top = fig.add_subplot(gs[0, 1]) # top-right bot = fig.add_subplot(gs[1, 1]) # bottom-right # Output: an uneven 3-panel layout with one tall left panel and two small panels stacked on the right.
5. Twin Axes: Two Y-Scales on One Plot
When two series share an x-axis but have very different scales (revenue in millions vs. conversion rate in percent), use a twin axis.
import matplotlib.pyplot as plt import numpy as np months = np.arange(1, 13) revenue = np.array([100, 110, 105, 130, 140, 160, 170, 165, 180, 190, 200, 220]) conversion = np.array([2.1, 2.3, 2.0, 2.4, 2.5, 2.7, 2.8, 2.6, 2.9, 3.0, 3.1, 3.2]) fig, ax1 = plt.subplots(figsize=(8, 4)) ax1.bar(months, revenue, color="#a8d0e6", label="Revenue") ax1.set_xlabel("Month") ax1.set_ylabel("Revenue (M USD)", color="#1f4e79") ax1.tick_params(axis="y", labelcolor="#1f4e79") ax2 = ax1.twinx() # shares the x-axis, with its own y-axis ax2.plot(months, conversion, color="#c0392b", marker="o", linewidth=2, label="Conversion %") ax2.set_ylabel("Conversion (%)", color="#c0392b") ax2.tick_params(axis="y", labelcolor="#c0392b") fig.suptitle("Revenue vs. Conversion, 2025") plt.show() # Output: blue bars (revenue) against the left y-axis, red line (conversion) against the right y-axis, # sharing the month x-axis. Each axis has tick labels in its series' colour.
Twin axes are easy to misuse. Use them when the two series are related and showing both shapes together helps the reader. Don't use them to squeeze two unrelated charts into one panel. That is chartjunk.
6. Annotations and Reference Lines
import matplotlib.pyplot as plt import numpy as np x = np.linspace(0, 10, 100) y = np.sin(x) fig, ax = plt.subplots() ax.plot(x, y) ax.axhline(0, color="grey", linestyle="--", linewidth=0.8) # horizontal reference ax.axvline(np.pi, color="red", linestyle=":", linewidth=1) # vertical at x=π # Plain text at a coordinate ax.text(np.pi, 0.5, "x = π", ha="center", color="red") # Annotation with arrow ax.annotate("max", xy=(np.pi / 2, 1), xytext=(4, 1.1), arrowprops=dict(arrowstyle="->")) ax.set_title("sin(x) on [0, 10]") plt.show() # Output: a sine wave, a dashed horizontal line at y=0, a dotted red vertical line at x=π with a label, # and an arrow pointing to the maximum at (π/2, 1).
axhline / axvline for thresholds (target lines, breakeven points, regulatory limits). annotate for callouts that need an arrow, and text for free-floating labels. Both take coordinates in data units by default, which is what you would expect.
7. Saving for Publication
import matplotlib.pyplot as plt fig, ax = plt.subplots(figsize=(7, 4)) ax.plot([1, 2, 3], [4, 5, 6]) ax.set_title("Demo") fig.savefig("out.png", dpi=300, bbox_inches="tight") fig.savefig("out.pdf", bbox_inches="tight") # vector format for slides/papers fig.savefig("out.svg", bbox_inches="tight") # vector format for the web
Two settings that matter:
dpi=300: print quality. The default (100) looks fine on a laptop screen, but looks blurry and blocky on a projector or on paper.bbox_inches="tight": removes the empty margin around the figure. Without it, you get a wide border around the plot. Always pass it for production charts.
For papers, prefer PDF or SVG. They are vector formats, so they stay sharp at any zoom. For web, PNG at 2× the display resolution, or SVG if the chart is simple enough that the markup stays small.
8. Style Sheets
Matplotlib's default look is old-fashioned. One line changes it for the whole script:
import matplotlib.pyplot as plt plt.style.use("seaborn-v0_8") # softer, statistical look plt.style.use("ggplot") # looks like R's ggplot plt.style.use("dark_background") # for dashboards on dark UIs plt.style.use("default") # reset # Combine: apply seaborn, then override the font size plt.style.use(["seaborn-v0_8", {"font.size": 12, "axes.titlesize": 14}])
For a single plot, use a context manager, so the style doesn't change the global settings:
with plt.style.context("dark_background"): fig, ax = plt.subplots() ax.plot([1, 2, 3], [1, 4, 9]) plt.show() # Output: a dark-background chart. The global style is unchanged after the block.
setup added so this can run · re-runs 9 earlier blocks on this page first
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) plt = _AutoMock('plt') # Earlier blocks on this page that this one builds on, re-run quietly first. try: import matplotlib.pyplot as plt import numpy as np from matplotlib.gridspec import GridSpec except Exception: pass _pm_earlier = [ 'import matplotlib.pyplot as plt\nimport numpy as np\n\n# The state-machine way works, but it acts on "the current axes", whichever that is\nplt.plot([1, 2, 3], [1, 4, 9])\nplt.title("squares")\nplt.show()\n\n# The OO (object-oriented) way: you keep the figure and axes in variables\nfig, ax = plt.subplots(figsize=(6, 4))\nax.plot([1, 2, 3], [1, 4, 9])\nax.set_title("squares")\nax.set_xlabel("x")\nax.set_ylabel("y")\nplt.show()\n# Output: a single-panel line chart, 6x4 inches, with axis labels.', 'import matplotlib.pyplot as plt\nimport numpy as np\n\nrng = np.random.default_rng(0)\n\n# 1. Line: trends over a continuous x\nx = np.linspace(0, 10, 100)\nfig, ax = plt.subplots()\nax.plot(x, np.sin(x), label="sin")\nax.plot(x, np.cos(x), label="cos", linestyle="--")\nax.legend()\nax.set_title("Trig over [0, 10]")\nplt.show()\n# Output: two curves on one axis (sin solid, cos dashed), legend where it hides the least data.\n\n# 2. Bar: compare categories\ncategories = ["EU", "US", "APAC", "LATAM"]\nrevenue = [120, 250, 180, 90]\nfig, ax = plt.subplots()\nax.bar(categories, revenue)\nax.set_ylabel("Revenue (M$)")\nax.set_title("Q1 Revenue by Region")\nplt.show()\n# Output: four vertical bars, EU/US/APAC/LATAM left to right.\n\n# 3. Histogram: the shape of a distribution\ndata = rng.standard_normal(1000)\nfig, ax = plt.subplots()\nax.hist(data, bins=30, edgecolor="black")\nax.set_xlabel("value")\nax.set_ylabel("frequency")\nax.set_title("N(0, 1) sample")\nplt.show()\n# Output: bell-shaped histogram with 30 bins, centred near 0.\n\n# 4. Scatter: the relationship between two continuous variables\nx = rng.standard_normal(200)\ny = 2 * x + rng.standard_normal(200)\nfig, ax = plt.subplots()\nax.scatter(x, y, alpha=0.5, s=20)\nax.set_xlabel("x")\nax.set_ylabel("y")\nax.set_title("y vs x (with noise)")\nplt.show()\n# Output: cloud of points along a positive diagonal, alpha=0.5 makes overlap visible.\n\n# 5. Boxplot: quartiles and outliers, one box per group\ngroups = [rng.standard_normal(100), rng.standard_normal(100) + 1, rng.standard_normal(100) - 0.5]\nfig, ax = plt.subplots()\nax.boxplot(groups)\nax.set_xticklabels(["A", "B", "C"]) # works on every matplotlib version\nax.set_ylabel("value")\nax.set_title("Distribution per group")\nplt.show()\n# Output: three boxes side by side. Each shows the median line, IQR box, whiskers and outlier dots.\n\n# 6. Heatmap: a 2-D matrix\nmatrix = rng.standard_normal((10, 10))\nfig, ax = plt.subplots()\nim = ax.imshow(matrix, cmap="viridis")\nfig.colorbar(im, ax=ax)\nax.set_title("Random 10x10")\nplt.show()\n# Output: a 10x10 coloured grid from dark purple (low) to yellow (high), colourbar on the right.', 'import matplotlib.pyplot as plt\nimport numpy as np\n\nx = np.arange(2020, 2026)\nus = [100, 120, 135, 150, 165, 180]\neu = [80, 90, 105, 115, 130, 140]\n\nfig, ax = plt.subplots(figsize=(7, 4))\n\nax.plot(x, us, marker="o", label="US", color="#1f77b4", linewidth=2)\nax.plot(x, eu, marker="s", label="EU", color="#ff7f0e", linewidth=2)\n\nax.set_title("Annual Revenue by Region", fontsize=14, fontweight="bold")\nax.set_xlabel("Year")\nax.set_ylabel("Revenue (M USD)")\nax.set_ylim(0, 200) # fixed range, so auto-scaling cannot exaggerate changes\nax.set_xticks(x) # exactly the years we plotted\nax.grid(True, axis="y", linestyle="--", alpha=0.4)\nax.legend(loc="upper left", frameon=False)\n\n# Annotate a specific event\nax.annotate("Acquisition", xy=(2023, 150), xytext=(2021.5, 175),\n arrowprops=dict(arrowstyle="->", color="grey"))\n\nplt.show()\n# Output: a two-line chart with US (blue, circles) and EU (orange, squares), \n# bold title, gridded y-axis, "Acquisition" callout pointing at the 2023 US data point.', 'import matplotlib.pyplot as plt\nimport numpy as np\n\nrng = np.random.default_rng(0)\n\nfig, axes = plt.subplots(2, 3, figsize=(12, 6))\ntitles = ["A", "B", "C", "D", "E", "F"]\n\nfor ax, title in zip(axes.flat, titles):\n ax.hist(rng.standard_normal(1000), bins=20)\n ax.set_title(title)\n ax.set_xlim(-4, 4) # shared scale for comparison\n\nfig.suptitle("Six samples from N(0, 1)", fontsize=14)\nfig.tight_layout()\nplt.show()\n# Output: a 2x3 grid of histograms, each titled A-F, all sharing the same x-axis range,\n# with a figure-level title above the grid.', 'import matplotlib.pyplot as plt\nfrom matplotlib.gridspec import GridSpec\n\nfig = plt.figure(figsize=(10, 6))\ngs = GridSpec(2, 2, figure=fig)\nbig = fig.add_subplot(gs[:, 0]) # left column, both rows\ntop = fig.add_subplot(gs[0, 1]) # top-right\nbot = fig.add_subplot(gs[1, 1]) # bottom-right\n# Output: an uneven 3-panel layout with one tall left panel and two small panels stacked on the right.', 'import matplotlib.pyplot as plt\nimport numpy as np\n\nmonths = np.arange(1, 13)\nrevenue = np.array([100, 110, 105, 130, 140, 160, 170, 165, 180, 190, 200, 220])\nconversion = np.array([2.1, 2.3, 2.0, 2.4, 2.5, 2.7, 2.8, 2.6, 2.9, 3.0, 3.1, 3.2])\n\nfig, ax1 = plt.subplots(figsize=(8, 4))\n\nax1.bar(months, revenue, color="#a8d0e6", label="Revenue")\nax1.set_xlabel("Month")\nax1.set_ylabel("Revenue (M USD)", color="#1f4e79")\nax1.tick_params(axis="y", labelcolor="#1f4e79")\n\nax2 = ax1.twinx() # shares the x-axis, with its own y-axis\nax2.plot(months, conversion, color="#c0392b", marker="o", linewidth=2, label="Conversion %")\nax2.set_ylabel("Conversion (%)", color="#c0392b")\nax2.tick_params(axis="y", labelcolor="#c0392b")\n\nfig.suptitle("Revenue vs. Conversion, 2025")\nplt.show()\n# Output: blue bars (revenue) against the left y-axis, red line (conversion) against the right y-axis,\n# sharing the month x-axis. Each axis has tick labels in its series\' colour.', 'import matplotlib.pyplot as plt\nimport numpy as np\n\nx = np.linspace(0, 10, 100)\ny = np.sin(x)\n\nfig, ax = plt.subplots()\nax.plot(x, y)\n\nax.axhline(0, color="grey", linestyle="--", linewidth=0.8) # horizontal reference\nax.axvline(np.pi, color="red", linestyle=":", linewidth=1) # vertical at x=π\n\n# Plain text at a coordinate\nax.text(np.pi, 0.5, "x = π", ha="center", color="red")\n\n# Annotation with arrow\nax.annotate("max", xy=(np.pi / 2, 1), xytext=(4, 1.1),\n arrowprops=dict(arrowstyle="->"))\n\nax.set_title("sin(x) on [0, 10]")\nplt.show()\n# Output: a sine wave, a dashed horizontal line at y=0, a dotted red vertical line at x=π with a label,\n# and an arrow pointing to the maximum at (π/2, 1).', 'import matplotlib.pyplot as plt\n\nfig, ax = plt.subplots(figsize=(7, 4))\nax.plot([1, 2, 3], [4, 5, 6])\nax.set_title("Demo")\n\nfig.savefig("out.png", dpi=300, bbox_inches="tight")\nfig.savefig("out.pdf", bbox_inches="tight") # vector format for slides/papers\nfig.savefig("out.svg", bbox_inches="tight") # vector format for the web', 'import matplotlib.pyplot as plt\n\nplt.style.use("seaborn-v0_8") # softer, statistical look\nplt.style.use("ggplot") # looks like R\'s ggplot\nplt.style.use("dark_background") # for dashboards on dark UIs\nplt.style.use("default") # reset\n\n# Combine: apply seaborn, then override the font size\nplt.style.use(["seaborn-v0_8", {"font.size": 12, "axes.titlesize": 14}])', ] def _pm_run_earlier(_srcs, _g): import sys as _s, io as _io, warnings as _w for _src in _srcs: _o, _e = _s.stdout, _s.stderr _s.stdout = _s.stderr = _io.StringIO() try: with _w.catch_warnings(): _w.simplefilter('ignore') exec(compile(_src, '<earlier block>', 'exec'), _g) except (Exception, SystemExit): pass finally: _s.stdout, _s.stderr = _o, _e if 'matplotlib.pyplot' in _s.modules: _s.modules['matplotlib.pyplot'].close('all') # their plots belong to their own blocks _pm_run_earlier(_pm_earlier, globals()) del _pm_earlier, _pm_run_earlier
9. Seaborn: When to Use It
Seaborn is matplotlib with statistical defaults and DataFrame awareness. A plot that takes 8 lines in matplotlib often takes 2 in seaborn, especially when it involves aggregation, grouping or confidence intervals.
import seaborn as sns import matplotlib.pyplot as plt import pandas as pd import numpy as np rng = np.random.default_rng(0) df = pd.DataFrame({ "day": np.tile(np.arange(7), 100), "value": rng.standard_normal(700) + np.tile([1, 2, 1, 3, 2, 4, 3], 100), "group": np.repeat(["A", "B"], 350), }) # Line plot with a confidence interval computed for you sns.lineplot(data=df, x="day", y="value", hue="group", errorbar="ci") plt.title("Mean value by day with 95% CI") plt.show() # Output: two lines (A and B), each showing the daily mean, surrounded by a shaded # 95% confidence band. Legend appears automatically from the `hue` column. # Bar with auto-aggregation across rows sns.barplot(data=df, x="day", y="value", hue="group") plt.show() # Output: grouped bars per day, two colours for A and B, error bars showing the confidence interval. # Pairwise scatter for an entire DataFrame # sns.load_dataset("iris") downloads this table from GitHub. scikit-learn ships # the same data, so this version needs no network and runs in the browser too. from sklearn.datasets import load_iris bunch = load_iris(as_frame=True) iris = bunch.frame.drop(columns="target") iris["species"] = bunch.target_names[bunch.target] sns.pairplot(iris, hue="species", diag_kind="hist") plt.show() # Output: a grid of scatter plots (off the diagonal) and histograms (on the diagonal), # coloured by species. Built in one line. # Correlation heatmap with annotations corr = iris.drop(columns="species").corr() sns.heatmap(corr, annot=True, cmap="coolwarm", center=0, fmt=".2f") plt.show() # Output: 4x4 heatmap of feature correlations, each cell labelled with the value, # diverging colour scale centred at 0.
When to use seaborn:
- You're plotting from a DataFrame and want
hue/size/styleto come from columns. - You want automatic aggregation with confidence intervals (
lineplot,barplot). - You want a quick statistical view (
pairplot,heatmap,violinplot,kdeplot).
When to stay on matplotlib:
- You need precise control over every visual element.
- You're not plotting from a DataFrame.
- You want a custom layout (gridspec, embedded panels, custom annotations).
A common pattern is to use seaborn for the data and matplotlib for the finishing details: build the plot with seaborn, then customise the resulting Axes (which seaborn returns) with matplotlib's OO API.
10. Colour: The Most Common Design Mistake
Default matplotlib colours are not colour-blind safe. About 8% of men can't tell red from green. If your chart uses those two colours for "positive" and "negative", part of your audience can't read it.
import seaborn as sns import matplotlib.pyplot as plt palette = sns.color_palette("colorblind") print(palette) # 10 RGB colours that people with the common types of colour blindness can tell apart fig, ax = plt.subplots() for i, c in enumerate(palette): ax.bar(i, 1, color=c) plt.show() # Output: 10 bars, each in a different colourblind-safe colour.
Rules:
- Categorical data: qualitative palettes:
"colorblind","tab10","Set2". - Sequential data (low → high): sequential palettes:
"viridis","magma","YlGnBu". Equal steps in the data look like equal steps in colour. - Diverging data (centred around 0): diverging palettes:
"coolwarm","RdBu_r". - Never use
"jet". It was matplotlib's default colour map before version 2.0. Equal steps in the data don't look like equal steps in colour, and it makes data look more dramatic than it is.
Test your chart in greyscale (print preview is the cheapest way). If two important series look the same, change the colours, or change the marker style so that colour is not the only difference.
11. Interactive: Plotly, Bokeh, Altair
Matplotlib produces static images. For dashboards, notebooks where users zoom and hover, or charts embedded in web apps, use:
plotly: interactive charts defined in JSON. It is great for hover tooltips and 3D, and it integrates with Dash for dashboards.bokeh: interactive HTML/JS output, with good support for streaming data and large datasets.altair: a declarative grammar (built on Vega-Lite), with compact code for complex statistical charts.
For exploratory work in a notebook, plotly is usually the quickest way to get a chart where you can hover over a point and see its values. For production dashboards, plotly+Dash or bokeh+Panel are the common stacks. Matplotlib remains the right tool for static reports, papers, slides, and anything you'll print.
Common Mistakes
1. Pie charts for more than ~5 categories
Humans are bad at reading angles. A bar chart with the same data is easier to compare. Use pie charts only to show two categories where one is much bigger, or don't use them at all.
2. Truncated y-axis exaggerating differences
# Misleading: y starts at 95, so a 1% change looks huge ax.set_ylim(95, 100) # Honest: y starts at 0 (or at the lowest value that makes sense) ax.set_ylim(0, 100)
setup added so this can run · re-runs 11 earlier blocks on this page first
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) ax = _AutoMock('ax') plt = _AutoMock('plt') # Earlier blocks on this page that this one builds on, re-run quietly first. try: import matplotlib.pyplot as plt import numpy as np from matplotlib.gridspec import GridSpec import seaborn as sns except Exception: pass _pm_earlier = [ 'import matplotlib.pyplot as plt\nimport numpy as np\n\n# The state-machine way works, but it acts on "the current axes", whichever that is\nplt.plot([1, 2, 3], [1, 4, 9])\nplt.title("squares")\nplt.show()\n\n# The OO (object-oriented) way: you keep the figure and axes in variables\nfig, ax = plt.subplots(figsize=(6, 4))\nax.plot([1, 2, 3], [1, 4, 9])\nax.set_title("squares")\nax.set_xlabel("x")\nax.set_ylabel("y")\nplt.show()\n# Output: a single-panel line chart, 6x4 inches, with axis labels.', 'import matplotlib.pyplot as plt\nimport numpy as np\n\nrng = np.random.default_rng(0)\n\n# 1. Line: trends over a continuous x\nx = np.linspace(0, 10, 100)\nfig, ax = plt.subplots()\nax.plot(x, np.sin(x), label="sin")\nax.plot(x, np.cos(x), label="cos", linestyle="--")\nax.legend()\nax.set_title("Trig over [0, 10]")\nplt.show()\n# Output: two curves on one axis (sin solid, cos dashed), legend where it hides the least data.\n\n# 2. Bar: compare categories\ncategories = ["EU", "US", "APAC", "LATAM"]\nrevenue = [120, 250, 180, 90]\nfig, ax = plt.subplots()\nax.bar(categories, revenue)\nax.set_ylabel("Revenue (M$)")\nax.set_title("Q1 Revenue by Region")\nplt.show()\n# Output: four vertical bars, EU/US/APAC/LATAM left to right.\n\n# 3. Histogram: the shape of a distribution\ndata = rng.standard_normal(1000)\nfig, ax = plt.subplots()\nax.hist(data, bins=30, edgecolor="black")\nax.set_xlabel("value")\nax.set_ylabel("frequency")\nax.set_title("N(0, 1) sample")\nplt.show()\n# Output: bell-shaped histogram with 30 bins, centred near 0.\n\n# 4. Scatter: the relationship between two continuous variables\nx = rng.standard_normal(200)\ny = 2 * x + rng.standard_normal(200)\nfig, ax = plt.subplots()\nax.scatter(x, y, alpha=0.5, s=20)\nax.set_xlabel("x")\nax.set_ylabel("y")\nax.set_title("y vs x (with noise)")\nplt.show()\n# Output: cloud of points along a positive diagonal, alpha=0.5 makes overlap visible.\n\n# 5. Boxplot: quartiles and outliers, one box per group\ngroups = [rng.standard_normal(100), rng.standard_normal(100) + 1, rng.standard_normal(100) - 0.5]\nfig, ax = plt.subplots()\nax.boxplot(groups)\nax.set_xticklabels(["A", "B", "C"]) # works on every matplotlib version\nax.set_ylabel("value")\nax.set_title("Distribution per group")\nplt.show()\n# Output: three boxes side by side. Each shows the median line, IQR box, whiskers and outlier dots.\n\n# 6. Heatmap: a 2-D matrix\nmatrix = rng.standard_normal((10, 10))\nfig, ax = plt.subplots()\nim = ax.imshow(matrix, cmap="viridis")\nfig.colorbar(im, ax=ax)\nax.set_title("Random 10x10")\nplt.show()\n# Output: a 10x10 coloured grid from dark purple (low) to yellow (high), colourbar on the right.', 'import matplotlib.pyplot as plt\nimport numpy as np\n\nx = np.arange(2020, 2026)\nus = [100, 120, 135, 150, 165, 180]\neu = [80, 90, 105, 115, 130, 140]\n\nfig, ax = plt.subplots(figsize=(7, 4))\n\nax.plot(x, us, marker="o", label="US", color="#1f77b4", linewidth=2)\nax.plot(x, eu, marker="s", label="EU", color="#ff7f0e", linewidth=2)\n\nax.set_title("Annual Revenue by Region", fontsize=14, fontweight="bold")\nax.set_xlabel("Year")\nax.set_ylabel("Revenue (M USD)")\nax.set_ylim(0, 200) # fixed range, so auto-scaling cannot exaggerate changes\nax.set_xticks(x) # exactly the years we plotted\nax.grid(True, axis="y", linestyle="--", alpha=0.4)\nax.legend(loc="upper left", frameon=False)\n\n# Annotate a specific event\nax.annotate("Acquisition", xy=(2023, 150), xytext=(2021.5, 175),\n arrowprops=dict(arrowstyle="->", color="grey"))\n\nplt.show()\n# Output: a two-line chart with US (blue, circles) and EU (orange, squares), \n# bold title, gridded y-axis, "Acquisition" callout pointing at the 2023 US data point.', 'import matplotlib.pyplot as plt\nimport numpy as np\n\nrng = np.random.default_rng(0)\n\nfig, axes = plt.subplots(2, 3, figsize=(12, 6))\ntitles = ["A", "B", "C", "D", "E", "F"]\n\nfor ax, title in zip(axes.flat, titles):\n ax.hist(rng.standard_normal(1000), bins=20)\n ax.set_title(title)\n ax.set_xlim(-4, 4) # shared scale for comparison\n\nfig.suptitle("Six samples from N(0, 1)", fontsize=14)\nfig.tight_layout()\nplt.show()\n# Output: a 2x3 grid of histograms, each titled A-F, all sharing the same x-axis range,\n# with a figure-level title above the grid.', 'import matplotlib.pyplot as plt\nfrom matplotlib.gridspec import GridSpec\n\nfig = plt.figure(figsize=(10, 6))\ngs = GridSpec(2, 2, figure=fig)\nbig = fig.add_subplot(gs[:, 0]) # left column, both rows\ntop = fig.add_subplot(gs[0, 1]) # top-right\nbot = fig.add_subplot(gs[1, 1]) # bottom-right\n# Output: an uneven 3-panel layout with one tall left panel and two small panels stacked on the right.', 'import matplotlib.pyplot as plt\nimport numpy as np\n\nmonths = np.arange(1, 13)\nrevenue = np.array([100, 110, 105, 130, 140, 160, 170, 165, 180, 190, 200, 220])\nconversion = np.array([2.1, 2.3, 2.0, 2.4, 2.5, 2.7, 2.8, 2.6, 2.9, 3.0, 3.1, 3.2])\n\nfig, ax1 = plt.subplots(figsize=(8, 4))\n\nax1.bar(months, revenue, color="#a8d0e6", label="Revenue")\nax1.set_xlabel("Month")\nax1.set_ylabel("Revenue (M USD)", color="#1f4e79")\nax1.tick_params(axis="y", labelcolor="#1f4e79")\n\nax2 = ax1.twinx() # shares the x-axis, with its own y-axis\nax2.plot(months, conversion, color="#c0392b", marker="o", linewidth=2, label="Conversion %")\nax2.set_ylabel("Conversion (%)", color="#c0392b")\nax2.tick_params(axis="y", labelcolor="#c0392b")\n\nfig.suptitle("Revenue vs. Conversion, 2025")\nplt.show()\n# Output: blue bars (revenue) against the left y-axis, red line (conversion) against the right y-axis,\n# sharing the month x-axis. Each axis has tick labels in its series\' colour.', 'import matplotlib.pyplot as plt\nimport numpy as np\n\nx = np.linspace(0, 10, 100)\ny = np.sin(x)\n\nfig, ax = plt.subplots()\nax.plot(x, y)\n\nax.axhline(0, color="grey", linestyle="--", linewidth=0.8) # horizontal reference\nax.axvline(np.pi, color="red", linestyle=":", linewidth=1) # vertical at x=π\n\n# Plain text at a coordinate\nax.text(np.pi, 0.5, "x = π", ha="center", color="red")\n\n# Annotation with arrow\nax.annotate("max", xy=(np.pi / 2, 1), xytext=(4, 1.1),\n arrowprops=dict(arrowstyle="->"))\n\nax.set_title("sin(x) on [0, 10]")\nplt.show()\n# Output: a sine wave, a dashed horizontal line at y=0, a dotted red vertical line at x=π with a label,\n# and an arrow pointing to the maximum at (π/2, 1).', 'import matplotlib.pyplot as plt\n\nfig, ax = plt.subplots(figsize=(7, 4))\nax.plot([1, 2, 3], [4, 5, 6])\nax.set_title("Demo")\n\nfig.savefig("out.png", dpi=300, bbox_inches="tight")\nfig.savefig("out.pdf", bbox_inches="tight") # vector format for slides/papers\nfig.savefig("out.svg", bbox_inches="tight") # vector format for the web', 'import matplotlib.pyplot as plt\n\nplt.style.use("seaborn-v0_8") # softer, statistical look\nplt.style.use("ggplot") # looks like R\'s ggplot\nplt.style.use("dark_background") # for dashboards on dark UIs\nplt.style.use("default") # reset\n\n# Combine: apply seaborn, then override the font size\nplt.style.use(["seaborn-v0_8", {"font.size": 12, "axes.titlesize": 14}])', 'with plt.style.context("dark_background"):\n fig, ax = plt.subplots()\n ax.plot([1, 2, 3], [1, 4, 9])\n plt.show()\n# Output: a dark-background chart. The global style is unchanged after the block.', 'import seaborn as sns\nimport matplotlib.pyplot as plt\n\npalette = sns.color_palette("colorblind")\nprint(palette)\n# 10 RGB colours that people with the common types of colour blindness can tell apart\n\nfig, ax = plt.subplots()\nfor i, c in enumerate(palette):\n ax.bar(i, 1, color=c)\nplt.show()\n# Output: 10 bars, each in a different colourblind-safe colour.', ] def _pm_run_earlier(_srcs, _g): import sys as _s, io as _io, warnings as _w for _src in _srcs: _o, _e = _s.stdout, _s.stderr _s.stdout = _s.stderr = _io.StringIO() try: with _w.catch_warnings(): _w.simplefilter('ignore') exec(compile(_src, '<earlier block>', 'exec'), _g) except (Exception, SystemExit): pass finally: _s.stdout, _s.stderr = _o, _e if 'matplotlib.pyplot' in _s.modules: _s.modules['matplotlib.pyplot'].close('all') # their plots belong to their own blocks _pm_run_earlier(_pm_earlier, globals()) del _pm_earlier, _pm_run_earlier
If the data is bounded (e.g. percentages), start at 0 by default. Start the axis higher only with a clear note, such as "Note: y-axis starts at 95%". Never do it without a note. It is a very common way for business charts to mislead, often by accident.
3. Too many colours / series on one plot
With more than about 6 series, readers struggle to match colours to the legend, and the lines overlap. Options: facet into small multiples (plt.subplots(2, 3)), highlight one series and make the rest grey, or aggregate into fewer groups.
4. Chartjunk: 3D bars, shadows, gradients
Every visual element should carry information. 3D bars distort comparison (the back row looks shorter). Drop shadows add ink without data. Gradient fills imply variation that isn't there. Edward Tufte's principle: maximise the data-ink ratio, the share of the chart's ink that shows data rather than decoration.
5. Not labelling units
"Revenue: 250" tells the reader nothing. Dollars? Thousands? Millions? Customers? Always include units in the axis label: Revenue (M USD). If the unit changes per series (mass and length on the same plot), label each series, not the axis.
6. Bare plt.something() instead of ax.something()
Once you have two charts in the same script, or put a chart inside a function, it becomes unclear which panel pyplot's "current axes" refers to. Build everything on an explicit ax from the start, so you never have to rewrite pyplot-state code in a hurry.
🎯 Your Turn: Two-Panel Distribution Plot
Write plot_distribution(values, title, save_path=None) that produces a single figure with two side-by-side subplots showing the same data two ways:
- Left panel: a histogram with 30 bins, with coloured bar edges so the bars are easy to tell apart.
- Right panel: a boxplot of the same values.
- One figure-level title above both panels.
- Both panels labelled appropriately.
- If
save_pathis provided, save withdpi=300andbbox_inches="tight".
Expected behaviour:
import numpy as np rng = np.random.default_rng(0) data = rng.standard_normal(1000) plot_distribution(data, title="Sample from N(0, 1)", save_path="dist.png") # Output: one figure with a 1x2 grid, histogram on the left and boxplot on the right, # both titled, with "Sample from N(0, 1)" as the figure suptitle. File saved to dist.png at 300 dpi.
setup added so this can run · defines plot_distribution
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) def plot_distribution(*_a, **_kw): print('-> plot_distribution() called') return _AutoMock('plot_distribution()')
Skeleton:
import matplotlib.pyplot as plt def plot_distribution(values, title, save_path=None): # TODO 1: create a figure with two side-by-side axes # TODO 2: histogram on the left axis (30 bins, with edges) # TODO 3: boxplot on the right axis # TODO 4: label both axes and set the figure suptitle # TODO 5: call fig.tight_layout() to prevent overlap # TODO 6: if save_path provided, savefig with dpi=300 and bbox_inches="tight" # TODO 7: return fig so the caller can show or further customise ...
Hint 1: Side-by-side panels
fig, (ax_hist, ax_box) = plt.subplots(1, 2, figsize=(10, 4)) creates one figure with two axes, unpacked into named variables. The 1, 2 means one row, two columns. figsize matters: a figure that is too narrow squashes both panels.
Hint 2: Suptitle vs ax.set_title
ax.set_title(...) labels a single panel. fig.suptitle(...) sits above the whole figure, which is what you want for the shared title. You can still title each panel ("Histogram", "Boxplot") so the reader knows which is which. Remember fig.tight_layout() at the end, or the suptitle will overlap the panel titles.
Show full solution
import matplotlib.pyplot as plt def plot_distribution(values, title, save_path=None): """Histogram and boxplot of `values` side by side, under one shared title.""" fig, (ax_hist, ax_box) = plt.subplots(1, 2, figsize=(10, 4)) # Left: histogram ax_hist.hist(values, bins=30, edgecolor="black", color="#4a90d9", alpha=0.85) ax_hist.set_title("Histogram") ax_hist.set_xlabel("value") ax_hist.set_ylabel("frequency") ax_hist.grid(True, axis="y", linestyle="--", alpha=0.4) # Right: boxplot ax_box.boxplot(values, vert=True, patch_artist=True, boxprops=dict(facecolor="#a8d0e6")) ax_box.set_title("Boxplot") ax_box.set_ylabel("value") ax_box.set_xticks([1]) ax_box.set_xticklabels(["data"]) fig.suptitle(title, fontsize=14, fontweight="bold") fig.tight_layout() if save_path is not None: fig.savefig(save_path, dpi=300, bbox_inches="tight") return fig import numpy as np rng = np.random.default_rng(0) # Normal distribution plot_distribution(rng.standard_normal(1000), title="N(0, 1)", save_path="normal.png") plt.show() # Skewed distribution: log-normal plot_distribution(rng.lognormal(0, 1, size=1000), title="Log-normal: note the long right tail") plt.show() # Bimodal: a mixture of two normal distributions mixed = np.concatenate([rng.standard_normal(500) - 3, rng.standard_normal(500) + 3]) plot_distribution(mixed, title="Bimodal: the histogram shows what the boxplot hides") plt.show()
Why both panels for the same data? Each one shows something the other doesn't:
- Histogram: the shape of the distribution: unimodal, bimodal, skewed, gaps. A boxplot reduces all of that to five numbers.
- Boxplot: robust summary statistics: median, IQR, whisker range, outlier dots. On a histogram, outliers are easy to miss unless you read the y-axis carefully.
The bimodal example shows this clearly. The boxplot looks like a roughly normal distribution with a wide IQR, while the histogram shows two separate peaks straight away. Each chart alone can mislead you, but together they are hard to misread. Seaborn's displot and pairplot follow the same idea: they show several views at once.
Why return fig? So the caller can keep working on the figure (change the style, add annotations, put it in a larger report) without you having to provide every option as a parameter. A function that builds and returns a figure is reusable, but a function that builds and calls plt.show() is a one-off script.
Extra features for a real pipeline:
- A small
summarytext box in a corner showing N, mean, median and std, the numbers a reader will look for anyway. - Sharing the data axis: in this layout, the histogram's x-axis and the boxplot's y-axis represent the same variable. Linking them (or using the same range) makes it clear that they are two views of one quantity.
- Saving in both PNG (for slides) and PDF (for documents) from a single function call.
- Optionally accepting a list of arrays for side-by-side comparison. At that point you have rebuilt
seaborn.displot, so call that instead.
What You Learned
- The
fig, ax = plt.subplots()OO API is better than the pyplot state machine for any chart you will keep. Always operate on explicitAxes. - Six chart types cover most analytic questions: line, bar, histogram, scatter, boxplot, heatmap. Choose by what you want to show.
- Every chart needs a title, axis labels with units, and (if multi-series) a legend. Sensible y-limits and explicit ticks fix most charts that look wrong.
plt.subplots(rows, cols)+axes.flatfor clean small-multiples layouts.gridspecfor irregular ones.- Twin axes (
ax.twinx()) for two related series with different scales. Use them rarely. - Annotations:
ax.text,ax.annotate,ax.axhline,ax.axvlinefor callouts and reference lines. - Save with
dpi=300, bbox_inches="tight". Use PDF/SVG for vector output. - Style sheets (
plt.style.use("seaborn-v0_8")) update the look in one line, and a context manager limits them to one plot. - Seaborn is better for statistical aggregation, DataFrame-aware plotting (
hue=,pairplot,heatmap), and prettier defaults. Use it for the data, and matplotlib for the finishing details. - Colour-blind safe palettes by default (
sns.color_palette("colorblind")). Use sequential for ordered data, diverging for ±, and neverjet. - Interactive needs → plotly, bokeh, altair. Static reports → matplotlib.
- Don't mislead with charts: avoid truncated y-axes, pie charts past 5 categories, chart junk, missing units, and bare
plt.something()in code that has to scale.
Next: work through a real analysis from start to finish, using everything from these three lessons. Load with pandas, compute with numpy, visualise here, and publish the report. See the projects track for end-to-end workflows.
Found this useful? Share it with a friend who’s learning Python.