How to Export a Map at Print Quality: DPI, Vector and Fonts

On this page

Problem statement

The map is finished. Now it has to leave Python, and the export decision has three parts that are usually made by accident:

  • Raster or vector. A PNG at the wrong DPI is either blurry or enormous; a PDF keeps text as text and scales perfectly.
  • DPI. 72 for screen, 300 for print, 600 for fine linework — and the pixel count follows the square, though a PNG of flat colours grows far more slowly.
  • Fonts. Matplotlib's default PDF font type is refused or substituted by some publishing workflows, and the SVG default converts every glyph to outlines.

Measured on one map — Natural Earth's 1:10m US states, 51 polygons and 53,352 vertices, as an 8 × 5 inch figure with a title, a legend and 51 labels (the setup under Code examples draws it):

import os

rows = []
for dpi in (72, 150, 300, 600):
    fig.savefig(f"map_{dpi}.png", dpi=dpi)
    rows.append((f"PNG {dpi:>3} dpi", f"map_{dpi}.png", "pixels", "no"))
fig.savefig("default.pdf")          # matplotlib's defaults, before the quick answer changes them
fig.savefig("default.svg")
rows += [("PDF vector", "default.pdf", "selectable", "yes"), ("SVG vector", "default.svg", "outlines", "yes")]

print(f"{'format':14} {'size':>9}   {'text':11} scales?")
for label, path, text, scales in rows:
    print(f"{label:14} {os.path.getsize(path) / 1024:>7,.0f} kB   {text:11} {scales}")
format              size   text        scales?
PNG  72 dpi         32 kB   pixels      no
PNG 150 dpi         84 kB   pixels      no
PNG 300 dpi        197 kB   pixels      no
PNG 600 dpi        445 kB   pixels      no
PDF vector         472 kB   selectable  yes
SVG vector       1,352 kB   outlines    yes

With matplotlib's defaults the SVG's text is not text at all: every glyph is written as an outline (step 4).

Quick answer

Vector for maps with text and linework, raster only for imagery — and set the font types explicitly:

import matplotlib.pyplot as plt

plt.rcParams.update({
    "pdf.fonttype": 42,       # TrueType subset, not Type 3
    "ps.fonttype": 42,
    "svg.fonttype": "none",   # keep <text> as text
})

fig.savefig("map.pdf")                       # print: vector, any size
fig.savefig("map.png", dpi=300)              # print: raster, if imagery
fig.savefig("map.svg")                       # editable, text preserved

Three lines of rcParams remove the two most common publisher complaints before they happen.

Choosing an export format from what the map contains. the format follows the contentwhat is the map mostly made of?lines and textPDFtext stays textimageryPNG or TIFF 300 dpivector adds nothingbothPDF + rasterizedlayerset dpi at save timeto be editedSVG, fonttype nonelargest, editableFor a line-and-text map the vector PDF was smaller than a 600 dpi raster and scales without limit spatialworkflow.io
The exception is genuine imagery, which has no vector description to preserve.

What you get — from 3. Set the font type for PDF and PostScript

PDF, Type 3 (default):   7.8 kB
PDF, TrueType (42):      7.9 kB

Raster or vector, DPI, font type, SVG text

1. Decide raster or vector from what the map contains

Map content Format Why
Vector layers, labels, legend PDF text stays text; sharp at any size
Satellite imagery, hillshade, big rasters PNG or TIFF at 300 dpi vector wrapping a raster gains nothing
Mixed — vector over a hillshade PDF, with the raster at explicit DPI best of both
Going into a design tool SVG with svg.fonttype="none" editable

The measurement above shows why the default should be vector: the PDF is a few hundred kilobytes, in the same range as a 600 dpi PNG of the same map, and reproduces at any size.

2. Choose the DPI from the output, not from habit

DPI only matters for raster output. The number depends on the destination:

  • 72–96 — screen only. A draft.
  • 150 — acceptable for internal documents and slides.
  • 300 — the standard print minimum, and what most journals require.
  • 600 — fine linework, large-format printing, or maps with hairline boundaries.

The pixel count scales with the square of DPI — 600 dpi is 69 times the pixels of 72 — but a PNG of flat fills compresses so well that the file grew far less than that in the measurement above. That is cheap; the reason not to default to 600 is render time on complex maps, not storage.

3. Set the font type for PDF and PostScript

Matplotlib writes Type 3 fonts by default. They are compact, and they are the reason journals send figures back.

plt.rcParams["pdf.fonttype"] = 42     # TrueType
plt.rcParams["ps.fonttype"] = 42

Measured on a one-line test figure:

import matplotlib.pyplot as plt

def one_line(path, **rc):
    with plt.rc_context(rc):
        test = plt.figure(figsize=(3, 1))
        test.text(0.05, 0.4, "Population density, 2024", fontsize=12)
        test.savefig(path)
        plt.close(test)
    return os.path.getsize(path) / 1024

print(f"PDF, Type 3 (default): {one_line('t3.pdf', **{'pdf.fonttype': 3}):5.1f} kB")
print(f"PDF, TrueType (42):    {one_line('t42.pdf', **{'pdf.fonttype': 42}):5.1f} kB")
PDF, Type 3 (default):   7.8 kB
PDF, TrueType (42):      7.9 kB

The cost, measured, is a tenth of a kilobyte. The benefit is text that is selectable, searchable and rendered in the font you chose.

4. Decide whether SVG keeps text or outlines

for mode in ("none", "path"):
    size = one_line(f"t_{mode}.svg", **{"svg.fonttype": mode})
    svg = open(f"t_{mode}.svg").read()
    print(f'svg.fonttype="{mode}": {size:4.1f} kB, {svg.count("<text")} <text>, '
          f'{svg.count("<path")} <path> elements')
svg.fonttype="none":  1.4 kB, 1 <text>, 1 <path> elements
svg.fonttype="path":  9.4 kB, 0 <text>, 19 <path> elements

"none" keeps the file small and editable but depends on the viewer having the font. "path" guarantees identical rendering anywhere and makes the text uneditable and unsearchable.

Use "none" when you control the environment — a design tool with your fonts installed, or a website that ships them. Use "path" when the SVG goes somewhere unknown.

5. Reduce the geometry before exporting a vector file

A vector file stores every vertex. The same map simplified to the tolerance its scale can show:

import shapely

for tolerance in (None, 200, 500, 1000):
    simplified = states if tolerance is None else states.assign(geometry=states.simplify(tolerance))
    figure = draw_map(simplified)
    figure.savefig("simplified.pdf")
    plt.close(figure)
    vertices = shapely.get_num_coordinates(simplified.geometry.values).sum()
    print(f"{'none' if tolerance is None else f'{tolerance:,} m':>8}  {vertices:>7,} vertices  "
          f"{os.path.getsize('simplified.pdf') / 1024:6,.0f} kB")
    none   53,352 vertices     470 kB
   200 m   34,484 vertices     312 kB
   500 m   22,590 vertices     211 kB
 1,000 m   14,943 vertices     146 kB

At 1:1,000,000 the resolvable ground distance is 200 m (0.2 mm on paper), so the 200 m file is visually identical to the full one. This figure is much smaller-scale than that — 8 inches for a continent — so even 1,000 m is invisible on it. Simplification is the correct fix for a large vector map; raising DPI or switching to raster is not.

6. Keep the figure at its final size

Every point-based size — type, line width, marker size — is physical. Export at the size you designed and place at 100%, or the scale factor multiplies all of them at once: an 8-inch figure placed in a 90 mm column scales by 0.443, turning 8 pt text into 3.54 pt.

7. Open the exported file and check it

Zoom the PDF to 100% and read the smallest text. Check that the fonts are the ones you chose, that no element is clipped, and that the raster components are sharp. Five seconds, and it catches everything above.

The three font settings that decide whether text survives an export. three settings, measured costsfile sizeresultpdf.fonttype 37.8 kB✕Type 3: often rejectedpdf.fonttype 427.9 kB✓TrueType subset embeddedsvg.fonttype 'none'1.4 kB✓text stays textsvg.fonttype 'path'9.4 kB!glyph outlines, no textA tenth of a kilobyte removes the most common reason a figure is sent back spatialworkflow.io
Measured on a one-line test figure, so the absolute sizes are small and the ratios hold.

Export presets for a map of US states

The data used on this page

The US states from Natural Earth's 1:10m admin-1 boundaries, projected to CONUS Albers (EPSG:5070): 51 polygons and 53,352 vertices. draw_map() is the figure every measurement on this page uses — filled by Census region, white boundaries, a title, a legend and the 51 postal codes as labels.

import os

import geopandas as gpd
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import shapely

if not os.path.exists("us_states.gpkg"):
    admin1 = gpd.read_file("https://naciscdn.org/naturalearth/10m/cultural/"
                           "ne_10m_admin_1_states_provinces.zip")
    admin1[admin1["adm0_a3"] == "USA"][["name", "postal", "region", "geometry"]].to_crs(5070).to_file(
        "us_states.gpkg")
states = gpd.read_file("us_states.gpkg")


def draw_map(gdf):
    figure, ax = plt.subplots(figsize=(8, 5))
    gdf.plot(ax=ax, column="region", categorical=True, cmap="Set2", edgecolor="white",
             linewidth=0.3, legend=True, legend_kwds={"loc": "lower left", "fontsize": 7,
                                                      "title": "Census region"})
    for _, row in gdf.iterrows():
        point = row.geometry.representative_point()
        ax.annotate(row["postal"], (point.x, point.y), ha="center", fontsize=5)
    ax.set_title("United States by Census region", fontsize=11)
    ax.set_axis_off()
    return figure


fig = draw_map(states)
print(len(states), "polygons,", f"{shapely.get_num_coordinates(states.geometry.values).sum():,} vertices,",
      fig.get_size_inches(), "inches")
51 polygons, 53,352 vertices, [8. 5.] inches

Example 1 — export presets, with the settings that matter baked in

import os
import matplotlib.pyplot as plt

PRINT_RC = {
    "pdf.fonttype": 42, "ps.fonttype": 42, "svg.fonttype": "none",
    "savefig.transparent": False, "savefig.facecolor": "white",
}

PRESETS = {
    "journal_pdf":  dict(format="pdf", dpi=300),
    "journal_tiff": dict(format="tiff", dpi=600, pil_kwargs={"compression": "tiff_lzw"}),
    "report_png":   dict(format="png", dpi=300),
    "slide_png":    dict(format="png", dpi=150),
    "web_png":      dict(format="png", dpi=144),
    "editable_svg": dict(format="svg"),
}


def export_map(fig, stem, preset="journal_pdf", verbose=True):
    with plt.rc_context(PRINT_RC):
        options = dict(PRESETS[preset])
        fmt = options.pop("format")
        path = f"{stem}.{fmt}"
        fig.savefig(path, **options)
    if verbose:
        w, h = fig.get_size_inches()
        print(f"{path:28} {os.path.getsize(path) / 1024:8,.0f} kB   "
              f"{w * 25.4:.0f} × {h * 25.4:.0f} mm   [{preset}]")
    return path


for preset in PRESETS:
    export_map(fig, f"states_{preset}", preset)
states_journal_pdf.pdf            470 kB   203 × 127 mm   [journal_pdf]
states_journal_tiff.tiff        1,004 kB   203 × 127 mm   [journal_tiff]
states_report_png.png             197 kB   203 × 127 mm   [report_png]
states_slide_png.png               84 kB   203 × 127 mm   [slide_png]
states_web_png.png                 80 kB   203 × 127 mm   [web_png]
states_editable_svg.svg         1,340 kB   203 × 127 mm   [editable_svg]

Example 2 — auditing what an export will contain

def export_audit(fig, target_width_mm=None, min_pt=6.0):
    """Vertices, text sizes and raster elements — before you export."""
    import matplotlib.text as mtext
    import matplotlib.image as mimage

    width_mm = fig.get_size_inches()[0] * 25.4
    scale = (target_width_mm / width_mm) if target_width_mm else 1.0

    vertices = 0
    for ax in fig.axes:
        for collection in ax.collections:
            for path in collection.get_paths():
                vertices += len(path.vertices)
        for line in ax.lines:
            vertices += len(line.get_xydata())

    texts = [t for t in fig.findobj(mtext.Text) if t.get_text().strip()]
    small = [(t.get_text()[:24], t.get_fontsize() * scale)
             for t in texts if t.get_fontsize() * scale < min_pt]
    rasters = fig.findobj(mimage.AxesImage)

    print(f"figure         {width_mm:.0f} mm wide"
          + (f", placed at {target_width_mm:.0f} mm (×{scale:.3f})" if target_width_mm else ""))
    print(f"vertices       {vertices:,}"
          + ("   <- simplify before a vector export" if vertices > 30_000 else ""))
    print(f"text objects   {len(texts)}, {len(small)} below {min_pt} pt after placement")
    for label, size in small[:6]:
        print(f"                 {label:26} {size:4.2f} pt")
    print(f"raster layers  {len(rasters)}"
          + ("   <- set their DPI explicitly" if rasters else ""))
    return {"vertices": vertices, "small_text": small, "rasters": len(rasters)}


audit = export_audit(fig, target_width_mm=90)
figure         203 mm wide, placed at 90 mm (×0.443)
vertices       53,352   <- simplify before a vector export
text objects   91, 91 below 6.0 pt after placement
                 WA                         2.21 pt
                 ID                         2.21 pt
                 MT                         2.21 pt
                 ND                         2.21 pt
                 MN                         2.21 pt
                 MI                         2.21 pt
raster layers  0

Example 3 — checking the fonts actually embedded

import subprocess


def check_pdf_fonts(path):
    """Requires poppler-utils. Type 3 in the output is the thing to avoid."""
    try:
        out = subprocess.run(["pdffonts", path], capture_output=True,
                             text=True, check=True).stdout
    except (FileNotFoundError, subprocess.CalledProcessError):
        print("pdffonts not available — check in a PDF viewer's document properties")
        return None

    print(out)
    if "Type 3" in out:
        print("! Type 3 fonts found — set plt.rcParams['pdf.fonttype'] = 42")
    header, _, *rows = out.splitlines()
    start = header.index("emb")                     # the column of yes/no
    if any(row[start:start + 3].strip() == "no" for row in rows):
        print("! a font is not embedded — the printer will substitute it")
    return out


check_pdf_fonts("default.pdf")
check_pdf_fonts("states_journal_pdf.pdf")
name                                 type              encoding         emb sub uni object ID
------------------------------------ ----------------- ---------------- --- --- --- ---------
GHMRKI+DejaVuSans                    Type 3            Custom           yes yes yes     15  0

! Type 3 fonts found — set plt.rcParams['pdf.fonttype'] = 42
name                                 type              encoding         emb sub uni object ID
------------------------------------ ----------------- ---------------- --- --- --- ---------
GHMRKI+DejaVuSans                    CID TrueType      Identity-H       yes yes yes     15  0

The first version of this function tested "no" in out.split("\n")[2:] — whether any whole line of the output was the word "no" — so it could never report a missing font.

Why vector wins, and Type 3 fonts lose

Why vector beats raster for most maps

A map is mostly lines, fills and text — all of which have exact mathematical descriptions. A raster export samples that onto a grid and throws the descriptions away, so any later enlargement re-samples pixels rather than redrawing shapes.

Vector output also keeps text as text, which means it can be searched, selected, and rendered at the printer's resolution rather than the file's. The measured PDF was 472 kB against 445 kB for a 600 dpi PNG — comparable in size, and unlimited in resolution.

The exception is genuine imagery. A satellite scene has no vector description; wrapping it in a PDF stores the same pixels with extra overhead.

Why Type 3 fonts cause trouble

Type 3 PDF fonts embed each glyph as a general PostScript drawing procedure rather than as a standard outline font. That makes them flexible and compact, and it means some RIPs, viewers and publisher pipelines handle them badly — substituting a different font, rendering them at the wrong weight, or refusing the file.

Type 42 embeds a TrueType subset, which every part of the chain understands. The measured cost was 0.1 kB on a one-line figure: 7.8 kB against 7.9.

Why DPI does nothing for a vector file

dpi in savefig sets the resolution of raster output, and of any raster elements inside a vector file. For pure vector content it has no effect — the geometry is stored as coordinates and rendered at the output device's resolution.

Passing dpi=300 to a PDF export is harmless and often useful, because it sets the resolution of embedded rasters such as a basemap or a hillshade.

Why simplification is the right fix for a huge vector map

A vector file's size is dominated by vertex count. The measured ladder shows the whole relationship: 53,352 vertices for 470 kB, and 14,943 for 146 kB.

Switching to raster to avoid a large PDF trades a solvable problem for a permanent one — the map stops scaling. Simplifying to the tolerance the map's scale can actually show removes vertices that were never going to be visible.

PNG file size at four DPI settings for the same US states map. 69 times the pixels, 14 times the bytes72 dpi — draft32 kB150 dpi — internal84 kB300 dpi — print197 kB600 dpi — fine line445 kB0250 kB500 kBDPI does nothing for pure vector content — it sets the resolution of embedded rasters spatialworkflow.io
The reason not to default to 600 is render time on complex maps, not storage.

Transparency, TIFF compression and hairlines

  • bbox_inches="tight" changes the physical size of the export, which breaks a print layout.
  • Transparency in PDF is handled inconsistently by some print workflows; flatten if the printer asks.
  • TIFF with LZW compression is what many journals want for raster figures.
  • Set the DPI of embedded rasters explicitly — a basemap at the default can be the blurry part of a sharp map.
  • savefig.facecolor defaults to white, but a transparent export over a coloured page can surprise you.
  • Check hairlines. Lines below about 0.25 pt can disappear on press.
  • Large SVGs slow browsers. Simplify, or export PNG for the web.
  • Keep the export function in a module so every figure in a report is exported identically.

FAQ

Should I export maps as PNG or PDF?

PDF for anything with vector layers and text — it keeps text as text and scales perfectly. PNG only when the map is mostly imagery.

What DPI do I need for print?

300 is the standard minimum, 600 for fine linework. Measured on one map: 197 kB at 300 dpi and 445 kB at 600.

Why do journals reject my matplotlib PDFs?

Usually the Type 3 fonts matplotlib writes by default. Set pdf.fonttype = 42 to embed TrueType subsets instead; measured on a one-line figure, the file grew from 7.8 to 7.9 kB.

Should SVG keep text or convert it to outlines?

svg.fonttype="none" keeps <text> and stays small and editable but needs the font available. "path" converts to outlines — measured, 1.4 kB became 9.4 kB — and renders identically anywhere.

My PDF is enormous. What do I do?

Simplify the geometry. Vertex count dominates vector file size: 53,352 vertices produced 470 kB and 14,943 produced 146 kB of the same map.

Does dpi matter for a PDF?

Only for raster elements inside it, such as a basemap. Pure vector content is stored as coordinates and rendered at the output device's resolution.