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.

Type sizes after an 8-inch figure is placed in a 90 millimetre column. drawn 203 mm, placed 90 mm โ†’ ร—0.443lands atagainst the 6 pt floor9 pt title3.99 ptโœ•below8 pt label3.54 ptโœ•below7 pt legend3.10 ptโœ•below5.5 pt footer2.44 ptโœ•belowNo font size survives being multiplied by 0.443 โ€” draw at the final size instead spatialworkflow.io
Every point-based size in the figure is multiplied by the same factor at once.

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 print layout with the map, a side panel for the legend and a footer for provenance. a grid, not nudged positionsthe layoutmap ยท equal aspectlegendsource ยท projection ยท date170 mm at final sizeGridSpec ratios survive a change of figure size; hand-nudged positions do not spatialworkflow.io
Give the map the space and give everything else the margins.

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.

Exported file sizes for one map across four PNG resolutions, PDF and SVG. the same 8 ร— 5 inch map, six waysPNG 72 dpi22 kBPNG 150 dpi57 kBPNG 300 dpi128 kBPNG 600 dpi287 kBPDF vector457 kBSVG vector1,309 kB01400The PDF scales to any size; the 600 dpi PNG does not, for a comparable number of bytes spatialworkflow.io
SVG is XML: every coordinate is text, which is why it is consistently the largest.

Two-column figures, margins and minimum line widths

  • Two-column figures need their own figsize, not a scaled single-column one.
  • constrained_layout fights manual GridSpec margins. 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.dpi affects only the screen preview, not the exported vector file.
  • Keep the layout function in a module so every figure in a report shares it.

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.