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")
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.
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.
Edge cases or notes
- Simplify for display only. Areas, lengths and topology change; keep the source geometry for measurement.
preserve_topology=Trueavoids self-intersections; a large tolerance can still collapse thin polygons.- Transparency forces rasterisation in some PDF workflows, which can silently enlarge the file.
rasterized=Trueneeds adpiat 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.
.svgzis 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.
Internal links
- Map scale explained: how much detail a scale can hold โ choosing the tolerance
- How to simplify geometry in GeoPandas โ the operation
- How to export a map at print quality โ format and DPI decisions
- How to reduce GIS file size โ the same problem for data files
- How to build a print-ready map layout in Matplotlib โ where export settings live
- Zoom and generalisation explained โ the web-map version of the same rule
- Fixing a GeoJSON that is too big for the browser โ the web equivalent
- How to build a reusable map style module โ export presets
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.