Fixing a Map Export That Is Hundreds of Megabytes

On this page

Problem statement

The PDF will not attach to an email. The SVG takes twenty seconds to open in a browser. The PNG is 180 MB.

All three have different causes and all three are usually fixed by one of four things:

  • too many vertices โ€” a vector file stores every one of them, including the ones no reader can see
  • too many objects โ€” one path per feature, and there are 83,508 of them
  • an embedded raster at the wrong resolution โ€” a hillshade stored at nearly twice the pixels the printed map can show
  • the wrong format โ€” a 600 dpi raster of a map that is mostly lines

Measured on one map โ€” the 51 US states and DC from Natural Earth, 53,352 vertices, drawn 8 ร— 5 inches (Example 2):

tolerance   vertices   PDF size
   none       53,352    455 kB
 1,000 m      14,943    133 kB
 2,000 m       9,470     86 kB
 5,000 m       5,140     47 kB
10,000 m       3,415     31 kB
20,000 m       2,532     23 kB

At 8 inches wide this map is drawn at about 1:66,700,000, where the smallest mark a reader can separate, 0.2 mm, is 13 km of ground. Every tolerance in the table is below that, so the 31 kB file is visually identical to the 455 kB one. Everything above that size is invisible detail being paid for.

Quick answer

Find which of the four causes it is, then fix that one:

def diagnose_export(fig, path=None):
    import os
    import matplotlib.image as mimage

    vertices = objects = 0
    for ax in fig.axes:
        for collection in ax.collections:
            paths = collection.get_paths()
            objects += len(paths)
            vertices += sum(len(p.vertices) for p in paths)
        for line in ax.lines:
            objects += 1
            vertices += len(line.get_xydata())

    rasters = fig.findobj(mimage.AxesImage)
    print(f"vertices        {vertices:,}"
          + ("   <- simplify" if vertices > 30_000 else ""))
    print(f"drawn objects   {objects:,}"
          + ("   <- dissolve or rasterise this layer" if objects > 20_000 else ""))
    print(f"raster layers   {len(rasters)}")
    for image in rasters:
        h, w = image.get_array().shape[:2]
        print(f"    {w} ร— {h} px" + ("   <- downsample" if w * h > 4e6 else ""))
    if path and os.path.exists(path):
        print(f"file size       {os.path.getsize(path) / 1e6:,.1f} MB")
Four causes of a very large map export and the fix for each. four causes, and only one fix per causecausefixtoo many verticessimplify to the scale tolerancetoo many drawn objectsdissolve, filter, or rasterizethat layeran oversampled embedded rastermatch pixels to the output sizethe wrong format for the contentvector for lines, raster forimageryFixing the wrong cause changes nothing โ€” diagnose before simplifying spatialworkflow.io
Simplifying a map whose bulk is an embedded raster achieves nothing.

Simplify, cut objects, rasterise the dense layer

1. Simplify to the tolerance the scale can show

A reader resolves about 0.2 mm on a page, so the ground distance of one visible mark is 0.0002 ร— scale denominator metres. At 1:1,000,000 that is 200 m; at 1:250,000 it is 50 m.

tolerance = 0.0002 * scale_denominator
plot_layer = gdf.assign(
    geometry=gdf.geometry.simplify(tolerance, preserve_topology=True))

This is the first thing to try and usually the only one needed. Simplify in the plotting pipeline, not in the stored data โ€” measurements taken from simplified geometry are measurements of an approximation.

2. Reduce the object count, not just the vertices

A file with tens of thousands of small polygons is large even when each is simple, because every one is a separate drawing operation with its own style attributes. Measured with the 83,508 census tracts of the conterminous US as a choropleth, the all-vector PDF was 38.7 MB and took 6.9 s to write (Example 3).

Options, in order of preference:

  • Dissolve features that share the symbology โ€” thousands of parcels coloured by one attribute become a handful of multipolygons.
  • Filter what will not be visible: features smaller than a few pixels at the output size.
  • Rasterise that layer only, keeping the rest vector.

3. Rasterise the dense layer and keep the rest vector

Matplotlib can rasterise individual artists inside a vector file. The result is a PDF whose text and boundaries stay sharp and whose dense layer is an image:

dense.plot(ax=ax, rasterized=True, zorder=2)     # becomes an image in the PDF
boundaries.plot(ax=ax, zorder=4)                 # stays vector
fig.savefig("map.pdf", dpi=300)                  # sets the rasterised layer's DPI

This is the right fix for a hillshade, a dense point cloud, a fine-grained choropleth of hundreds of thousands of parcels, or anything with transparency.

4. Check the resolution of embedded rasters

A raster carries its own pixel count into the file whenever it is embedded as it is. Measured with one Copernicus DEM tile, 3,600 pixels square, in a 170 mm figure at 300 dpi that can show 2,007: embedded pixel for pixel (interpolation="none"), the PDF was 3.68 MB, and 1.50 MB with the tile read at 2,007 pixels. Matplotlib's default interpolation resamples an image to the output resolution before writing it, and then both came to about 1.1 MB โ€” so the cause bites when native pixels are kept on purpose, or when another tool places the raster.

Match the raster's pixel count to the output: width_mm / 25.4 ร— dpi pixels across is enough, and anything beyond that is waste.

5. Choose the format from the content, not from habit

The measured comparison for the same unsimplified states map (Example 3):

PNG   72 dpi     24 kB
PNG  150 dpi     62 kB
PNG  300 dpi    142 kB
PNG  600 dpi    327 kB
PDF (vector)    457 kB
SVG (vector)  1,318 kB

For a line-and-text map, PDF at 457 kB is a better deal than a 600 dpi PNG at 327 kB, because it scales to any size. For a map dominated by imagery, the raster is both smaller and equivalent.

SVG is consistently the largest of the three vector-capable options โ€” 1,318 kB against the PDF's 457 kB for identical content โ€” so export SVG only when the file will be edited.

6. Re-measure after each change

Each fix addresses one cause. Simplifying a map whose size comes from an embedded 8,000-pixel basemap changes nothing, and the diagnosis function tells you that before you spend an hour on it.

PDF size tracking vertex count almost exactly across six simplification levels. bytes follow vertices53,352 vertices455 kB14,943 (1 km)133 kB9,470 (2 km)86 kB5,140 (5 km)47 kB3,415 (10 km)31 kB2,532 (20 km)23 kB0240 kB480 kB3.6ร— fewer vertices gave 3.4ร— fewer bytes โ€” the bytes are the data, so compression will not help spatialworkflow.io
Measured in Example 2. Every tolerance here is below the 13 km this map can show.

Auditing three oversized layers

The data used on this page

Three real layers, one for each cause: the 51 US states and District of Columbia boundaries from Natural Earth at 1:10 million; the Census Bureau's 2023 cartographic boundary tracts for the conterminous states, 1:500,000; and one Copernicus 30 m DEM tile over the Alps, the raster a map might carry as a hillshade. All three are projected before plotting โ€” the states and tracts to Albers equal-area (EPSG:5070).

import io
import os
import time

import geopandas as gpd
import matplotlib

matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import rasterio
import requests

SOURCES = {
    "states.zip": "https://naciscdn.org/naturalearth/10m/cultural/ne_10m_admin_1_states_provinces.zip",
    "tracts.zip": "https://www2.census.gov/geo/tiger/GENZ2023/shp/cb_2023_us_tract_500k.zip",
}
for name, url in SOURCES.items():
    if not os.path.exists(name):
        reply = requests.get(url, headers={"User-Agent": "spatialworkflow.io guide"}, timeout=300)
        reply.raise_for_status()
        with open(name, "wb") as out:
            out.write(reply.content)

states = gpd.read_file("states.zip")
states = states[states["adm0_a3"] == "USA"][["name", "geometry"]].to_crs(5070)
tracts = gpd.read_file("tracts.zip")
tracts = tracts[~tracts["STATEFP"].isin(["02", "15", "60", "66", "69", "72", "78"])].to_crs(5070)
with rasterio.open("https://copernicus-dem-30m.s3.amazonaws.com/Copernicus_DSM_COG_10_N46_00_E010_00_DEM/"
                   "Copernicus_DSM_COG_10_N46_00_E010_00_DEM.tif") as dem:
    elevation = dem.read(1)


def vertex_count(layer):
    return int(sum(len(ring.coords) for geom in layer.geometry
                   for poly in getattr(geom, "geoms", [geom])
                   for ring in [poly.exterior, *poly.interiors]))


print(f"states: {len(states)} polygons, {vertex_count(states):,} vertices")
print(f"tracts: {len(tracts):,} polygons, {vertex_count(tracts):,} vertices")
print(f"DEM: {elevation.shape[1]:,} ร— {elevation.shape[0]:,} px")
states: 51 polygons, 53,352 vertices
tracts: 83,508 polygons, 4,607,494 vertices
DEM: 3,600 ร— 3,600 px

Example 1 โ€” the full size audit

import os
import matplotlib.pyplot as plt
import matplotlib.image as mimage


def size_audit(fig, target_width_mm=170, target_dpi=300):
    vertices = objects = 0
    per_layer = []

    for ax in fig.axes:
        for collection in ax.collections:
            paths = collection.get_paths()
            n_v = sum(len(p.vertices) for p in paths)
            per_layer.append((type(collection).__name__, len(paths), n_v,
                              bool(collection.get_rasterized())))
            objects += len(paths)
            vertices += n_v

    print(f"{'layer':22} {'objects':>9} {'vertices':>10}  rasterised")
    for name, n_obj, n_v, raster in sorted(per_layer, key=lambda r: -r[2])[:8]:
        print(f"{name:22} {n_obj:9,} {n_v:10,}  {'yes' if raster else 'no'}")

    needed_px = int(target_width_mm / 25.4 * target_dpi)
    for image in fig.findobj(mimage.AxesImage):
        h, w = image.get_array().shape[:2]
        waste = w / needed_px
        print(f"\nraster {w} ร— {h} px; the output needs {needed_px} px across")
        if waste > 1.3:
            print(f"  ! {waste:.1f}ร— more pixels than the output can show")

    print(f"\ntotal {objects:,} objects, {vertices:,} vertices")
    if vertices > 30_000:
        print("  โ†’ simplify to the tolerance your scale can show")
    if objects > 20_000:
        print("  โ†’ dissolve, filter, or set rasterized=True on the dense layer")

Example 2 โ€” the simplification ladder, with sizes

import io
import matplotlib.pyplot as plt


def size_ladder(gdf, scale_denominator=None,
                tolerances=(100, 200, 500, 1000, 2000, 5000)):
    """What each tolerance costs and saves, on your data."""
    def vertices(layer):
        return sum(len(g.exterior.coords) if g.geom_type == "Polygon"
                   else sum(len(p.exterior.coords) for p in g.geoms)
                   for g in layer.geometry if g and not g.is_empty)

    if scale_denominator:
        print(f"at 1:{scale_denominator:,} the resolvable ground distance is "
              f"{0.0002 * scale_denominator:,.0f} m\n")

    print(f"{'tolerance':>10} {'vertices':>10} {'PDF kB':>9}")
    for tol in (None, *tolerances):
        layer = gdf if tol is None else gdf.assign(
            geometry=gdf.geometry.simplify(tol, preserve_topology=True))
        buf = io.BytesIO()
        fig, ax = plt.subplots(figsize=(8, 5))
        layer.plot(ax=ax, edgecolor="white", linewidth=0.4)
        ax.set_axis_off()
        fig.savefig(buf, format="pdf", bbox_inches="tight")
        plt.close(fig)
        label = "none" if tol is None else f"{tol:,} m"
        print(f"{label:>10} {vertices(layer):10,} {buf.tell() / 1024:9,.0f}")

The states at 8 ร— 5 inches: the scale follows from the map's width in metres and the width it is drawn at.

fig, ax = plt.subplots(figsize=(8, 5))
states.plot(ax=ax)
ax.set_axis_off()
fig.savefig(io.BytesIO(), format="pdf", bbox_inches="tight")      # settles the layout
drawn_m = ax.get_window_extent().width / fig.dpi * 0.0254
scale = round((ax.get_xlim()[1] - ax.get_xlim()[0]) / drawn_m, -5)
plt.close(fig)
size_ladder(states, scale_denominator=int(scale), tolerances=(1000, 2000, 5000, 10000, 20000))
at 1:66,700,000 the resolvable ground distance is 13,340 m

 tolerance   vertices    PDF kB
      none     53,352       455
   1,000 m     14,943       133
   2,000 m      9,470        86
   5,000 m      5,140        47
  10,000 m      3,415        31
  20,000 m      2,532        23

The polygon-only vertex counter inside size_ladder counts exterior rings. The states have holes and islands, so it runs slightly under the vertex_count used in the setup; both are fine for the comparison.

Example 3 โ€” selective rasterisation

def plot_mixed(ax, dense_layer, vector_layers, dpi=300):
    """Rasterise only the layer that is expensive; keep everything else vector."""
    dense_layer.plot(ax=ax, column="d", cmap="YlGnBu", rasterized=True, zorder=2, linewidth=0)

    for layer, style in vector_layers:
        layer.plot(ax=ax, zorder=style.pop("zorder", 4), **style)

    ax.set_rasterization_zorder(3)      # anything below zorder 3 is rasterised
    return ax

The three remaining causes, measured. First the format, for the unsimplified states; then the dense layer โ€” every tract as a vector path, and the same map with the tracts rasterised and the state boundaries kept vector; then an embedded raster, the full DEM tile against one sampled to the pixels a 170 mm figure at 300 dpi can show.

def saved_kb(fig, fmt, **kwargs):
    buffer = io.BytesIO()
    fig.savefig(buffer, format=fmt, **kwargs)
    return buffer.tell() / 1024


fig, ax = plt.subplots(figsize=(8, 5))
states.plot(ax=ax, edgecolor="white", linewidth=0.4)
ax.set_axis_off()
for dpi in (72, 150, 300, 600):
    print(f"PNG {dpi:3} dpi   {saved_kb(fig, 'png', dpi=dpi):7,.0f} kB")
print(f"PDF (vector)  {saved_kb(fig, 'pdf'):7,.0f} kB")
print(f"SVG (vector)  {saved_kb(fig, 'svg'):7,.0f} kB")
plt.close(fig)

density = np.log10(tracts["ALAND"].clip(lower=1e4))
for mode in ("all vector", "tracts rasterised"):
    fig, ax = plt.subplots(figsize=(8, 5))
    if mode == "all vector":
        tracts.assign(d=density).plot(column="d", ax=ax, linewidth=0, cmap="YlGnBu")
        states.boundary.plot(ax=ax, color="#1a3a6b", linewidth=0.3)
    else:
        plot_mixed(ax, tracts.assign(d=density).assign(),
                   [(states.boundary, {"color": "#1a3a6b", "linewidth": 0.3, "zorder": 4})])
    ax.set_axis_off()
    started = time.perf_counter()
    kb = saved_kb(fig, "pdf", dpi=300)
    print(f"\n{mode:18} PDF {kb:9,.0f} kB, saved in {time.perf_counter() - started:.1f} s")
    if mode == "all vector":
        diagnose_export(fig)
    plt.close(fig)

from rasterio.enums import Resampling

needed = int(170 / 25.4 * 300)                      # pixels a 170 mm, 300 dpi figure can show
with rasterio.open(dem.name) as source:
    fitted = source.read(1, out_shape=(needed, needed), resampling=Resampling.average)
print()
for interpolation in ("antialiased", "none"):       # matplotlib's default, then native pixels
    for label, array in (("full tile", elevation), (f"read at {needed} px", fitted)):
        fig, ax = plt.subplots(figsize=(170 / 25.4, 170 / 25.4))
        ax.imshow(array, cmap="Greys_r", interpolation=interpolation)
        ax.set_axis_off()
        print(f"embedded DEM, interpolation={interpolation!r:14} {label:16} "
              f"PDF {saved_kb(fig, 'pdf', dpi=300) / 1024:,.2f} MB")
        if interpolation == "none" and label == "full tile":
            size_audit(fig)
        plt.close(fig)
PNG  72 dpi        24 kB
PNG 150 dpi        62 kB
PNG 300 dpi       142 kB
PNG 600 dpi       327 kB
PDF (vector)      457 kB
SVG (vector)    1,318 kB

all vector         PDF    38,673 kB, saved in 6.9 s
vertices        4,660,846   <- simplify
drawn objects   84,787   <- dissolve or rasterise this layer
raster layers   0

tracts rasterised  PDF       627 kB, saved in 0.3 s

embedded DEM, interpolation='antialiased'  full tile        PDF 1.08 MB
embedded DEM, interpolation='antialiased'  read at 2007 px  PDF 1.09 MB
embedded DEM, interpolation='none'         full tile        PDF 3.68 MB
layer                    objects   vertices  rasterised

raster 3600 ร— 3600 px; the output needs 2007 px across
  ! 1.8ร— more pixels than the output can show

total 0 objects, 0 vertices
embedded DEM, interpolation='none'         read at 2007 px  PDF 1.50 MB

set_rasterization_zorder is the cleaner mechanism when several layers should be flattened: everything below the given zorder becomes one image, everything above stays vector.

Vertices you cannot see

Why vector file size is dominated by vertex count

A vector file lists coordinates. Each vertex is a pair of numbers plus the path structure around it, so the file grows roughly linearly with the number of vertices โ€” which the measured ladder shows exactly: 53,352 vertices for 457 kB and 14,943 for 133 kB, a ratio of 3.6 in vertices and 3.4 in bytes.

That is why simplification is the fix and compression is not. The bytes are the data.

Why the invisible vertices are the ones to remove

Simplification looks destructive at full zoom, where you are examining detail the map will never show. At the map's actual scale, a vertex closer to its neighbour than 0.2 mm on the page cannot be drawn as a separate point.

Removing those vertices does not change what the reader sees; it changes what the file carries. On this page's map, a 10 km tolerance โ€” still below what the scale can show โ€” removed 94% of them.

Why one dense layer can dominate everything

The 83,508 census tracts are 83,508 drawing operations and, with the state lines, 4.66 million vertices, and the PDF must describe every one โ€” including the ones that merge into areas no reader can tell apart at eight inches. Measured, that was 38.7 MB and 6.9 s to write.

Rasterising exactly that layer replaces the whole mass with one image at the output's resolution โ€” 627 kB, written in 0.3 s โ€” while leaving the state boundaries and text sharp.

Why SVG is the largest vector format

SVG is XML. Every coordinate is written as text, every path carries attributes, and there is no binary compression unless the file is served as .svgz. The measured comparison โ€” 1,318 kB against 457 kB for the same map as PDF โ€” shows the cost.

That is fine for a file destined for a design tool and wasteful for one destined for a document, which is what PDF is for.

Rasterising only the dense layer while keeping boundaries and text vector. flatten the expensive layer, keep the rest sharpall vectorevery tract is a path83,508 paths: 38.7 MBdense layer rasterisedboundaries and text stay vectorone image + lines: 627 kBset_rasterization_zorder flattens everything below one zorder into a single image spatialworkflow.io
The reader perceives the dense layer as one grey mass, so storing it as one image loses nothing.

Display-only simplification and forced rasterisation

  • Simplify for display only. Areas, lengths and topology change; keep the source geometry for measurement.
  • preserve_topology=True avoids self-intersections; a large tolerance can still collapse thin polygons.
  • Transparency forces rasterisation in some PDF workflows, which can silently enlarge the file.
  • rasterized=True needs a dpi at save time, or the layer is rasterised at the figure default.
  • Check the raster's pixel count against the output size โ€” a 4ร— oversampled basemap is 16ร— the pixels.
  • .svgz is gzipped SVG and is served transparently by most web servers.
  • Dissolving changes the data, so dissolve a copy for plotting.
  • Re-measure after each change; fixing the wrong cause changes nothing.

FAQ

Why is my map PDF so large?

Almost always the vertex count. Measured on one map, 53,352 vertices produced 457 kB and 14,943 produced 133 kB of the same picture.

How much can I simplify without visible change?

To the ground distance of 0.2 mm at your map scale: 0.0002 ร— scale denominator metres. The states map on this page, drawn 8 inches wide, is about 1:66,700,000 โ€” 13 km โ€” and a 10 km tolerance left 3,415 of its 53,352 vertices and 31 kB of 455.

Should I switch to PNG to make the file smaller?

Only if the map is mostly imagery. For a line-and-text map, a vector PDF at 457 kB beat a 600 dpi PNG at 327 kB because it scales to any size.

What do I do about a layer with hundreds of thousands of features?

Dissolve it, filter it, or set rasterized=True on that layer alone so it becomes one image inside an otherwise vector file.

Why is my SVG three times the size of the PDF?

SVG is XML: every coordinate is text and there is no binary compression. Measured, the same map was 1,318 kB as SVG and 457 kB as PDF.

Does simplifying lose data?

It loses detail the output cannot show. Simplify in the plotting pipeline and keep the full-detail geometry for anything you measure.