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.

Bar chart of type sizes after scaling, all below the 6 pt print floor.
Every point-based size in the figure is multiplied by the same factor at once.

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.

A print layout diagram with a map area, a legend column and a footer strip.
Give the map the space and give everything else the margins.

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.

Bar chart of export sizes for four PNG resolutions, a PDF and an SVG of the same map.
SVG is XML: every coordinate is text, which is why it is consistently the largest.

Edge cases or notes

  • 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: 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.