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")
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.
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.
Display-only simplification and forced rasterisation
- 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. 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.