How to Build a Print-Ready Map Layout in Matplotlib
Problem statement
A map that looks right on screen usually fails in a document, and the failures are all dimensional:
- the text is illegible, because an 8-inch figure placed in a 90 mm column is scaled by 0.443, turning 8 pt type into 3.54 pt
- the layout shifts, because the legend and the title were positioned by trial and error against a screen-sized figure
- the file is either a 300 MB PDF or a blurry PNG, because nobody decided which
- the fonts substitute, because the PDF was written with Type 3 fonts and the printer had opinions
A print layout is a figure whose physical size is decided first and whose every element is sized relative to that. Once that discipline is in place, the rest is arrangement.
Quick answer
Set the physical size from the destination, then never scale the figure again:
import matplotlib.pyplot as plt
MM = 1 / 25.4 # millimetres to inches
COLUMN_MM = 90 # a journal column
PAGE_MM = 170 # full text width
fig = plt.figure(figsize=(COLUMN_MM * MM, COLUMN_MM * MM * 0.75))
plt.rcParams.update({
"font.size": 7, # 7 pt is 7 pt, because the figure is life-size
"pdf.fonttype": 42, # embed TrueType, not Type 3
"svg.fonttype": "none", # keep text as text
"savefig.bbox": "standard", # not "tight" โ it changes the size you set
})
fig.savefig("map.pdf") # placed at 100%, nothing scales
The rule that makes everything else work: create the figure at its final printed size and place it at 100%. Every type-size problem downstream comes from breaking it.
Step-by-step solution
1. Get the physical dimensions before anything else
Ask for the column width in millimetres. Common values: 90 mm for a single journal column, 170โ180 mm for a full text width, 210 mm minus margins for A4. Then set figsize in inches to match.
If you draw at 8 inches and the document scales you to 90 mm, everything shrinks by 0.443 โ a measurement, not an estimate. Type set at 8 pt arrives at 3.54 pt, which is below the size at which most people can read.
2. Lay out with a grid, not by hand
GridSpec gives a reproducible arrangement. Give the map the space and give everything else the margins:
from matplotlib.gridspec import GridSpec
fig = plt.figure(figsize=(170 * MM, 120 * MM))
gs = GridSpec(2, 2, figure=fig,
width_ratios=[1, 0.28], height_ratios=[1, 0.14],
wspace=0.04, hspace=0.05)
ax_map = fig.add_subplot(gs[0, 0])
ax_legend = fig.add_subplot(gs[0, 1])
ax_footer = fig.add_subplot(gs[1, :])
The alternative โ nudging bbox_to_anchor values until it looks right โ produces a layout that breaks the moment the figure size changes, which it will.
3. Fix the aspect ratio before you fit the extent
A map axis must be equal-aspect or the projection is distorted:
ax_map.set_aspect("equal")
Then set the extent from the data with a small margin, and remember that the axes will letterbox: an axis wider than the data leaves white space at the sides, which is where the legend or the scale bar can go.
4. Size every element in points, not in fractions
Line widths, font sizes and marker sizes in matplotlib are in points, which are physical units. Because the figure is at its final size, those numbers mean what they say:
STYLE = {
"title": dict(fontsize=9, fontweight="bold"),
"labels": dict(fontsize=7),
"legend": dict(fontsize=6.5, title_fontsize=7),
"footer": dict(fontsize=5.5, color="#64748b"),
"boundary": dict(linewidth=0.4),
"coastline": dict(linewidth=0.6),
}
The floor for print is about 6 pt; below that, most readers struggle and many journals refuse. Since 6 pt is 2.12 mm and 8 pt is 2.82 mm, there is not much room to economise.
5. Put the apparatus where the map is not
A legend inside the map competes with the data; a legend outside it costs width. The compromise that usually wins is a dedicated narrow axis beside or below the map, styled to disappear:
ax_legend.set_axis_off()
handles, labels = build_class_handles(colours, breaks)
ax_legend.legend(handles=handles, labels=labels, loc="upper left",
frameon=False, **STYLE["legend"])
6. Export deliberately
Vector for anything with text and lines; raster only for imagery. The measured sizes for one map at 8 ร 5 inches:
format size
PNG 72 dpi 22 kB screen preview only
PNG 150 dpi 57 kB acceptable for a draft
PNG 300 dpi 128 kB the usual print minimum
PNG 600 dpi 287 kB fine detail or large format
PDF (vector) 457 kB text stays text, scales perfectly
SVG (vector) 1,309 kB editable, largest
The vector files are bigger here because the geometry is detailed โ 53,352 vertices. Simplifying to the tolerance the scale can show cut the PDF to 198 kB with no visible change.
7. Check the exported file, at size
Open the PDF, zoom to 100%, and read the smallest text. Print it if the destination is paper. Everything in this guide exists because on-screen inspection at 150% hides all four failure modes.
Code examples
Example 1 โ a complete, reusable layout function
import matplotlib.pyplot as plt
from matplotlib.gridspec import GridSpec
MM = 1 / 25.4
def print_layout(width_mm=170, aspect=0.72, legend_width=0.26, footer_height=0.12):
"""A figure at its final printed size, with places for map, legend and footer."""
plt.rcParams.update({
"font.size": 7,
"font.family": "sans-serif",
"pdf.fonttype": 42,
"ps.fonttype": 42,
"svg.fonttype": "none",
"axes.linewidth": 0.5,
"figure.dpi": 150, # screen preview only; export sets its own
})
fig = plt.figure(figsize=(width_mm * MM, width_mm * aspect * MM))
gs = GridSpec(2, 2, figure=fig,
width_ratios=[1 - legend_width, legend_width],
height_ratios=[1 - footer_height, footer_height],
wspace=0.03, hspace=0.02,
left=0.01, right=0.99, top=0.99, bottom=0.01)
ax_map = fig.add_subplot(gs[0, 0])
ax_side = fig.add_subplot(gs[0, 1])
ax_foot = fig.add_subplot(gs[1, :])
ax_map.set_aspect("equal")
ax_map.set_axis_off()
ax_side.set_axis_off()
ax_foot.set_axis_off()
return fig, ax_map, ax_side, ax_foot
def finish(fig, ax_foot, *, sources, projection, made_on=None):
from datetime import date
ax_foot.text(0, 0.6,
f"Source: {' ยท '.join(sources)} Projection: {projection} "
f"Made: {made_on or date.today().isoformat()}",
fontsize=5.5, color="#64748b", va="center", ha="left",
transform=ax_foot.transAxes)
Example 2 โ checking the layout will survive the document
def print_check(fig, target_width_mm, min_pt=6.0):
"""Will anything be illegible once this figure is placed?"""
drawn_width_mm = fig.get_size_inches()[0] * 25.4
scale = target_width_mm / drawn_width_mm
print(f"drawn at {drawn_width_mm:.0f} mm, placed at {target_width_mm:.0f} mm "
f"โ scale {scale:.3f}")
if abs(scale - 1) > 0.01:
print(" ! the figure will be rescaled โ every size below is multiplied by "
f"{scale:.3f}")
problems = []
for text in fig.findobj(plt.Text):
if not text.get_text().strip():
continue
effective = text.get_fontsize() * scale
if effective < min_pt:
problems.append((text.get_text()[:28], text.get_fontsize(), effective))
if problems:
print(f"\n{len(problems)} text objects below {min_pt} pt after placement:")
for label, drawn, effective in problems[:8]:
print(f" {label:30} {drawn:4.1f} pt โ {effective:4.2f} pt")
else:
print(f" all text is at least {min_pt} pt after placement")
return not problems
drawn at 203 mm, placed at 90 mm โ scale 0.443
! the figure will be rescaled โ every size below is multiplied by 0.443
4 text objects below 6.0 pt after placement:
Population density (per kmยฒ) 8.0 pt โ 3.54 pt
0 โ 250 7.0 pt โ 3.10 pt
Source: ONS 2025 5.5 pt โ 2.44 pt
N 8.0 pt โ 3.54 pt
Example 3 โ export presets that match their destinations
PRESETS = {
"print_vector": dict(format="pdf", dpi=300, bbox_inches=None,
metadata={"Creator": "spatialworkflow"}),
"print_raster": dict(format="png", dpi=300, bbox_inches=None),
"web": dict(format="png", dpi=144, bbox_inches=None),
"editable": dict(format="svg", bbox_inches=None),
"draft": dict(format="png", dpi=100, bbox_inches="tight"),
}
def export(fig, stem, preset="print_vector"):
import os
options = dict(PRESETS[preset])
fmt = options.pop("format")
path = f"{stem}.{fmt}"
fig.savefig(path, **options)
print(f"{path} {os.path.getsize(path) / 1024:,.0f} kB ({preset})")
return path
bbox_inches=None in every print preset is deliberate. bbox_inches="tight" crops to the drawn content, which changes the figure's physical size โ the one thing a print layout must not do.
Explanation
Why bbox_inches="tight" breaks a print layout
It is the right default for a quick figure and wrong for a layout. It crops the saved image to the bounding box of what was drawn, so the exported file is no longer the size you set โ and if two figures in the same document are cropped by different amounts, their type sizes no longer match after placement.
Set generous margins in GridSpec instead, and export at the size you designed.
Why pdf.fonttype matters
Matplotlib defaults to Type 3 fonts in PDF, which embed glyph outlines as PostScript procedures. They are compact โ measured, a small test figure was 7.3 kB with Type 3 and 11.6 kB with TrueType โ and they are refused or mishandled by several publishers and some PDF viewers, which substitute a different font.
Setting pdf.fonttype = 42 embeds proper TrueType subsets. The file grows by a few kilobytes and the text is selectable, searchable and rendered as designed.
Why svg.fonttype = "none" is usually right
With "path", matplotlib converts every glyph to outlines: measured, a one-line test figure went from 1.4 kB to 7.2 kB and the <text> elements disappeared entirely, replaced by fifteen <path> elements.
Outlines guarantee identical rendering anywhere; text keeps the file small, editable and searchable. For an SVG that will be opened in a design tool or a browser you control the fonts for, keep the text. For an SVG going to an unknown environment, use paths.
Why the scale factor is the root of most print failures
Almost every "the text is too small" report resolves to the same arithmetic: a figure drawn at a screen-comfortable 8 to 10 inches, then placed in a 90 mm column. That is a factor of 0.443, and it applies to every point-based size in the figure at once โ type, line widths, marker sizes, the scale bar's label.
Drawing at the final size makes all of those numbers literal, and the print_check function above turns the remaining risk into a list.
Edge cases or notes
- Two-column figures need their own
figsize, not a scaled single-column one. constrained_layoutfights manualGridSpecmargins. Use one or the other.- Journals often specify a minimum line width โ 0.25 pt is a common floor.
- Raster elements inside a vector export (a basemap, a hillshade) keep their own DPI; set it explicitly.
- Colour management is not handled by matplotlib. For CMYK proofing, convert downstream.
- Check the fonts exist on the machine that renders the PDF, or embed them.
figure.dpiaffects only the screen preview, not the exported vector file.- Keep the layout function in a module so every figure in a report shares it.
Internal links
- How to export a map at print quality: DPI, vector and fonts โ the export decisions in depth
- Fixing map text that is too small in the exported file โ the scale-factor failure, diagnosed
- Fixing missing or substituted fonts in an exported map PDF โ the font settings
- Which map elements are actually required โ what goes in the layout
- How to add a scale bar and north arrow to a Python map โ placing the apparatus
- How to add an inset and locator map in Python โ the extra axis
- How to build a reusable map style module โ sharing the layout
- How to save a map image with matplotlib โ the simpler case
FAQ
What figure size should I use for print?
The size it will be printed at. A 90 mm journal column means figsize=(90/25.4, ...). Drawing at 8 inches and placing at 90 mm scales everything by 0.443.
Why is my map text tiny in the document?
Because the figure was rescaled on placement. Measured, an 8-inch figure in a 90 mm column turns 8 pt type into 3.54 pt.
Should I use bbox_inches="tight"?
Not for a print layout โ it crops the file to the content and changes the physical size you set. Use generous GridSpec margins instead.
PDF or PNG for a printed map?
PDF, unless the map is mostly imagery. Text stays text, lines stay sharp at any size, and the file is usually smaller than a 600 dpi raster of the same map.
What DPI do I need for a raster map?
300 dpi is the usual print minimum; 600 for fine linework or large format. Measured on one map: 128 kB at 300 dpi and 287 kB at 600.
Why do fonts change when the PDF is printed?
Matplotlib defaults to Type 3 fonts, which some workflows substitute. Set pdf.fonttype = 42 to embed TrueType subsets instead.