PYTHON / MATPLOTLIB
Styles, colour choices, and saving figures
Control Matplotlib's look through rcParams and styles, pick colours that match the data's meaning, and save figures at exact pixel sizes.
What you will learn
- Apply a style globally with plt.style.use or temporarily with plt.style.context
- Read and set individual settings through plt.rcParams, reset with plt.rcdefaults()
- Pick discrete colours for categories and a perceptually uniform colormap for magnitude
- Save with fig.savefig(path, dpi=..., bbox_inches='tight') before calling plt.show()
Understanding Styles, colour choices, and saving figures
Every default in Matplotlib comes from one dictionary, plt.rcParams: line widths, font sizes, whether grids are drawn, the colour cycle, even the default figure size. A style such as "ggplot" or "grayscale" is nothing more magical than a named batch of edits to that dictionary, so plt.style.use('ggplot') changes future figures but never touches ones already drawn. Precedence runs in one direction: an explicit keyword like color="#0072B2" on a plot call beats the style, the style beats the built-in defaults. Because plt.style.use is global and sticky, prefer plt.style.context(...) as a with-block when you only want the look changed for one figure.
Colour is doing one of three different jobs, and choosing badly is a correctness problem, not a taste problem. For unordered categories you want colours that are merely distinguishable, which is what the property cycle gives you; C0 through C9 are not fixed colours but positions in the current cycle, so they change when the style changes, while tab:blue and #1f77b4 are fixed. For a quantity that has an order you want a sequential colormap like viridis, which is perceptually uniform, so equal steps in the data look like equal steps in brightness; rainbow maps like jet invent bright bands that read as features in the data that are not there. For data with a meaningful centre, such as anomalies around zero, use a diverging map like coolwarm and centre the norm on that value.
Saving is a fresh render, not a screenshot: savefig redraws the figure into the requested backend, so the file can differ from what a window showed. For raster formats the pixel size is figsize multiplied by dpi, which is why figsize=(4, 3) at dpi=200 gives 800x600; for PDF and SVG the geometry is vector and dpi only affects embedded images. bbox_inches="tight" recomputes the bounding box from the artists actually drawn, which is how you stop long tick labels and legends from being clipped, at the price of no longer knowing the exact output size in advance. Call savefig before show, because some interactive backends hand the figure to the GUI and clear it, leaving you with a blank file.
import matplotlib
matplotlib.use("Agg") # write files, no GUI window needed
import matplotlib.pyplot as plt
import os
x = [0, 1, 2, 3, 4]
with plt.style.context("ggplot"):
fig, ax = plt.subplots(figsize=(4, 3))
ax.plot(x, [v * v for v in x], color="#0072B2", linewidth=2, label="v squared")
ax.plot(x, [v * 3 for v in x], color="#D55E00", linestyle="--", label="3v")
ax.set_title("explicit colours override the style cycle")
ax.legend()
fig.savefig("plot.png", dpi=150, bbox_inches="tight")
fig.savefig("plot.svg")
plt.close(fig)
print("png written:", os.path.exists("plot.png"))
with open("plot.svg") as f:
print("svg begins with:", f.read(5))
print("cycle outside the context:", plt.rcParams["axes.prop_cycle"].by_key()["color"][:3])
print("grid outside the context:", plt.rcParams["axes.grid"])A style is just a batch of rcParams edits, and saving re-renders the figure, so pixel size, cropping and colour all follow from settings you can inspect and set yourself.
Worked examples
Styles are scoped rcParams edits
Shows that a style context changes settings only inside the with-block, while direct rcParams assignment persists until reset.
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
print("start grid:", plt.rcParams["axes.grid"])
with plt.style.context("ggplot"):
print("inside ggplot:", plt.rcParams["axes.grid"])
print("after context:", plt.rcParams["axes.grid"])
plt.rcParams["axes.grid"] = True
plt.rcParams["figure.dpi"] = 110
print("after manual set:", plt.rcParams["axes.grid"], plt.rcParams["figure.dpi"])
plt.rcdefaults()
print("after rcdefaults:", plt.rcParams["axes.grid"], plt.rcParams["figure.dpi"])Example explained
Line 1ggplot sets axes.grid to True, which is why the value flips inside the block only.
Line 2Leaving the with-block restores every key the style touched, so unrelated code is unaffected.
Line 3Assigning to plt.rcParams is global and lasts for the rest of the process.
Line 4plt.rcdefaults() throws away all such edits and returns to the factory defaults, including figure.dpi 100.
What a colour spec actually resolves to
Resolves colormap endpoints, cycle references and grey strings into concrete hex values.
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib import colors
viridis = plt.get_cmap("viridis")
print("viridis low: ", colors.to_hex(viridis(0.0)))
print("viridis high:", colors.to_hex(viridis(1.0)))
print("C0 ->", colors.to_hex("C0"))
print("tab:cyan ->", colors.to_hex("tab:cyan"))
print("grey '0.5' ->", colors.to_hex("0.5"))Example explained
Line 1A colormap is a callable from 0.0-1.0 to an RGBA tuple, so viridis(0.0) is its dark purple end.
Line 2"C0" is a lookup into axes.prop_cycle, so it would resolve differently under another style.
Line 3"tab:cyan" and "#17becf" are the same fixed colour and never move with the style.
Line 4A numeric string like "0.5" is a grey level, not a colour name: 0 is black, 1 is white.
dpi times figsize is the pixel size
Reads the width and height straight out of the PNG header to confirm how dpi controls raster output size.
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import struct
def png_size(path):
with open(path, "rb") as f:
header = f.read(24)
return struct.unpack(">II", header[16:24])
fig, ax = plt.subplots(figsize=(4, 2))
ax.plot([1, 3, 2])
fig.savefig("a.png", dpi=100)
fig.savefig("b.png", dpi=200)
plt.close(fig)
print("dpi=100:", png_size("a.png"))
print("dpi=200:", png_size("b.png"))
print("figsize inches:", (4.0, 2.0))Example explained
Line 1Bytes 16-24 of a PNG hold the IHDR width and height as big-endian unsigned ints.
Line 2The same figure object saved twice gives 400x200 and 800x400 because pixels equal inches times dpi.
Line 3Font sizes are in points, so doubling dpi keeps text the same size relative to the plot; doubling figsize would not.
Line 4No bbox_inches is passed here, which is why the sizes come out exactly as predicted.
Cropping and transparency at save time
Demonstrates that bbox_inches='tight' changes the saved image dimensions because the box is derived from the drawn artists.
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import struct
def png_size(path):
with open(path, "rb") as f:
return struct.unpack(">II", f.read(24)[16:24])
fig, ax = plt.subplots(figsize=(4, 3))
ax.plot([0, 1], [0, 1], color="tab:green")
ax.set_ylabel("a deliberately long axis label")
fig.savefig("plain.png", dpi=100)
fig.savefig("tight.png", dpi=100, bbox_inches="tight", transparent=True)
plt.close(fig)
print("plain:", png_size("plain.png"))
print("tight is smaller than plain:", png_size("tight.png") < png_size("plain.png"))Example explained
Line 1Without bbox_inches the canvas is exactly figsize times dpi, padding and all.
Line 2bbox_inches="tight" measures the real extent of axes, labels and legend, so the output size is no longer predictable.
Line 3transparent=True only blanks the figure and axes background in the saved file; the on-screen figure is unchanged.
Line 4Both saves come from the same figure object, so saving several formats or crops needs no replotting.
Important notes
Style names are not stable across versions: the bundled seaborn styles were renamed to seaborn-v0_8-* in Matplotlib 3.6, so check plt.style.available rather than hardcoding an old name.
dpi is meaningless for the vector geometry in PDF and SVG output; raise it only when the figure contains images or rasterized artists.
Common mistakes
Calling plt.show() first and fig.savefig() afterwards: with an interactive backend the figure may already have been consumed, so the file on disk is blank.
Using plt.savefig() after plt.close() or after creating another figure: it saves the current figure, which is not the one you meant, giving an empty image.
Leaving out bbox_inches='tight' (or tight_layout) when labels are long: the saved PNG is cropped to figsize and the y-label or legend is cut off, even though the notebook preview looked fine.
Encoding an ordered quantity with jet or with random named colours: the bright bands in jet read as structure that is not in the data.
Try it yourself
Change, predict, then run
Draw two lines inside a with plt.style.context('ggplot') block using the explicit colours #0072B2 and #D55E00, add a long y-axis label, then save the same figure twice as PNG at dpi=200, once plain and once with bbox_inches='tight'. Print plt.rcParams['axes.grid'] after the block to confirm the style did not leak.
Open the Python workspaceCheck your understanding
You save the same plot two ways: figsize=(4, 3) with dpi=300, and figsize=(12, 9) with dpi=100. What differs between the two PNG files?
- Both are 1200x900 pixels, but in the second the labels and lines take up a much smaller fraction of the image
- The files are identical, because dpi and figsize are interchangeable ways of asking for the same picture
- The first is 1200x900 and the second is 400x300, because dpi overrides figsize
- The second has larger text, because enlarging the figure scales every element up with it
Show answer
Pixel dimensions are figsize multiplied by dpi, so both files are 1200x900. They are not identical, though: font sizes and line widths are specified in points, which are physical units tied to inches, so on a 12x9 inch canvas the same 10-point label covers a third of the relative width it did on a 4x3 inch canvas. That also rules out the option claiming the bigger figure has larger text: increasing figsize enlarges the canvas, not the type.