How to Build a Print-Ready Map Layout in Matplotlib
On this page
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 needlessly heavy 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.
What you get โ from The data used on this page
51 countries
Physical size first, then grid, aspect and points
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 of Europe at 8 ร 5 inches (Example 4):
format size
PNG 72 dpi 41 kB screen preview only
PNG 150 dpi 110 kB acceptable for a draft
PNG 300 dpi 262 kB the usual print minimum
PNG 600 dpi 575 kB fine detail or large format
PDF (vector) 772 kB text stays text, scales perfectly
SVG (vector) 2,107 kB editable, largest
The vector files are bigger here because the geometry is detailed โ 85,555 vertices. Simplifying to 2.5 km, about the width of a 0.1 mm line at this scale, left 13,686 vertices and cut the PDF to 140 kB.
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.
A reusable layout for a European density map
The data used on this page
The map is Europe from Natural Earth's 1:10m Admin 0 countries, in the LAEA Europe projection (EPSG:3035), shaded by population density from the file's own POP_EST column. draw_demo_map() draws it as the screen-sized 8 ร 5 inch figure this page warns about; Examples 2 and 4 measure that figure.
import geopandas as gpd
import mapclassify
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
from matplotlib.colors import ListedColormap
world = gpd.read_file("https://naciscdn.org/naturalearth/10m/cultural/ne_10m_admin_0_countries.zip")
europe = (world[world["CONTINENT"] == "Europe"].to_crs(3035)
.clip((2_500_000, 1_300_000, 7_500_000, 5_500_000)))
europe = europe[europe["POP_EST"] > 0].copy()
europe["density"] = europe["POP_EST"] / (europe.area / 1e6)
def draw_demo_map(gdf=europe, simplify_m=None):
if simplify_m:
gdf = gdf.assign(geometry=gdf.geometry.simplify(simplify_m, preserve_topology=True))
classes = mapclassify.Quantiles(gdf["density"], k=5)
colours = plt.cm.Blues(np.linspace(0.25, 1, 5))
fig, ax = plt.subplots(figsize=(8, 5))
gdf.assign(cls=classes.yb).plot(column="cls", cmap=ListedColormap(colours), ax=ax,
linewidth=0.3, edgecolor="white")
ax.set_axis_off()
ax.set_aspect("equal")
ax.set_title("Population density (per kmยฒ)", fontsize=8)
edges = [0, *classes.bins]
ax.legend([plt.Rectangle((0, 0), 1, 1, color=c) for c in colours],
[f"{lo:,.0f} โ {hi:,.0f}" for lo, hi in zip(edges[:-1], edges[1:])],
fontsize=7, loc="upper left", frameon=False)
ax.text(0.97, 0.95, "N", fontsize=8, ha="center", transform=ax.transAxes)
fig.text(0.01, 0.01, "Source: Natural Earth 1:10m, POP_EST", fontsize=5.5)
return fig
print(len(europe), "countries")
51 countries
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}")
hidden = {id(t) for ax in fig.axes if not ax.axison # ticks of a switched-off
for axis in (ax.xaxis, ax.yaxis) for t in axis.findobj(plt.Text)} # axis are never drawn
problems = []
for text in fig.findobj(plt.Text):
if id(text) in hidden or not text.get_visible() or 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
print_check(draw_demo_map(), target_width_mm=90)
drawn at 203 mm, placed at 90 mm โ scale 0.443
! the figure will be rescaled โ every size below is multiplied by 0.443
8 text objects below 6.0 pt after placement:
N 8.0 pt โ 3.54 pt
Population density (per kmยฒ) 8.0 pt โ 3.54 pt
0 โ 45 7.0 pt โ 3.10 pt
45 โ 82 7.0 pt โ 3.10 pt
82 โ 122 7.0 pt โ 3.10 pt
122 โ 274 7.0 pt โ 3.10 pt
274 โ 67,602 7.0 pt โ 3.10 pt
Source: Natural Earth 1:10m, 5.5 pt โ 2.44 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.
Example 4 โ the file sizes in step 6
import os
import shapely
fig = draw_demo_map()
for dpi in (72, 150, 300, 600):
fig.savefig(f"demo_{dpi}.png", dpi=dpi)
print(f"PNG {dpi:3d} dpi {os.path.getsize(f'demo_{dpi}.png') / 1024:7,.0f} kB")
for fmt in ("pdf", "svg"):
fig.savefig(f"demo.{fmt}")
print(f"{fmt.upper():14} {os.path.getsize(f'demo.{fmt}') / 1024:7,.0f} kB")
plt.close(fig)
print(f"{int(shapely.get_num_coordinates(europe.geometry.values).sum()):,} vertices")
# 203 mm across ~5,000 km: a 0.1 mm line is about 2.5 km on the ground
simple = draw_demo_map(simplify_m=2_500)
simple.savefig("demo_simplified.pdf")
plt.close(simple)
simplified = europe.geometry.simplify(2_500, preserve_topology=True)
print(f"simplified to 2.5 km: {int(shapely.get_num_coordinates(simplified.values).sum()):,} vertices, "
f"PDF {os.path.getsize('demo_simplified.pdf') / 1024:,.0f} kB")
PNG 72 dpi 41 kB
PNG 150 dpi 110 kB
PNG 300 dpi 262 kB
PNG 600 dpi 575 kB
PDF 772 kB
SVG 2,107 kB
85,555 vertices
simplified to 2.5 km: 13,686 vertices, PDF 140 kB
Tight bounding boxes, font types and scale factors
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.
Two-column figures, margins and minimum line widths
- 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
- How to draw a strip map of events along a route โ a layout for one-dimensional data
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 of Europe: 262 kB at 300 dpi and 575 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.