Fixing a Map Export That Is Hundreds of Megabytes

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 400,000 features
  • an embedded raster at the wrong resolution โ€” a basemap stored at 600 dpi behind a map that will be printed at 300
  • the wrong format โ€” a 600 dpi raster of a map that is mostly lines

Measured on one map โ€” 51 polygons, 53,352 vertices, an 8 ร— 5 inch figure โ€” the whole size ladder:

tolerance   vertices   PDF size
   none       53,352    457 kB
   200 m      34,484    298 kB
   500 m      22,590    198 kB
 1,000 m      14,943    133 kB
 5,000 m        5,140     47 kB

At 1:1,000,000 the ground distance corresponding to one visible dot is 200 m, so the 298 kB file is visually identical to the 457 kB one. Everything above that tolerance 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")
Triage table of four causes of a large map export and their fixes.
Simplifying a map whose bulk is an 8,000 pixel basemap achieves nothing.

Step-by-step solution

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 400,000 tiny polygons is large even if each has four vertices, because every one is a separate drawing operation with its own style attributes.

Options, in order of preference:

  • Dissolve features that share the symbology โ€” 400,000 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 basemap fetched at zoom 14 for a map printed at 100 mm wide can easily be four times the resolution the output needs. The extra pixels are stored and never seen.

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 map:

PNG   72 dpi     22 kB
PNG  150 dpi     57 kB
PNG  300 dpi    128 kB
PNG  600 dpi    287 kB
PDF (vector)    457 kB
SVG (vector)  1,309 kB

For a line-and-text map, PDF at 457 kB is a better deal than a 600 dpi PNG at 287 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,309 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.

Bar chart of PDF size against vertex count across six simplification levels.
This is why simplification is the fix and zipping the PDF is not.

Code examples

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}")

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, 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

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.

Explanation

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 small-scale maps that is routinely 60โ€“80% of them.

Why one dense layer can dominate everything

A map with 300,000 point features has 300,000 drawing operations, each with its own path. Even at four vertices each, that is 1.2 million vertices, and the PDF must describe every one โ€” including the ones that overlap into a solid mass a reader perceives as a single grey area.

Rasterising exactly that layer replaces the whole mass with one image at the output's resolution, typically a few hundred kilobytes, while leaving the 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,309 kB against 457 kB for the same map as PDF โ€” is typical.

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.

Two panels: a fully vector dense layer and the same map with that layer rasterised.
The reader perceives the dense layer as one grey mass, so storing it as one image loses nothing.

Edge cases or notes

  • 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. At 1:1,000,000 that is 200 m, which removed 35% of vertices with no visible difference.

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 287 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,309 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.