Optimizing SVG File Size for Web Map Delivery

Optimizing SVG file size for web map delivery comes down to four levers applied in order: simplify geometry to the export scale, round coordinates to a scale-appropriate number of decimals, deduplicate repeated symbols into a single <defs> block, then minify with scour or svgo and serve over Brotli. Excess coordinate precision and redundant path data account for the overwhelming majority of map-SVG bloat, so those two levers alone routinely cut an exported basemap from over a megabyte to well under 300 KB before compression — and Brotli takes it from there.

Core Algorithm and Workflow

A map SVG is mostly numbers. Every d attribute on every <path> is a stream of coordinate pairs, and a coastline or road network exported straight from matplotlib or GDAL carries far more vertices and far more decimal places than any screen can resolve. The optimization workflow attacks the byte budget in the order that removes the most irrecoverable entropy first, then hands the residue to a compressor.

SVG byte composition before and after optimization Two horizontal stacked bars. The "before" bar totals about 1.0 MB, dominated by coordinate precision, followed by path data, defs, and editor metadata. The "after" bar totals about 265 KB, with every segment sharply reduced, coordinate precision shrinking the most. Before coordinate precision dominates ~1.0 MB After ~265 KB coordinates path data defs metadata
  1. Simplify geometry to the export scale. A vertex spacing finer than one output pixel carries no visible information but full byte cost. Run Douglas-Peucker or Visvalingam-Whyatt before export at a tolerance derived from the target scale. The general theory behind picking that tolerance is covered in Generalization and Simplification.
  2. Round coordinates to a scale-appropriate precision. This is the single largest lever. Trimming a road network from 6 decimal places to 2 removes four bytes from most coordinate tokens; across millions of coordinates that is a multi-hundred-kilobyte saving that no compressor can recover afterward.
  3. Deduplicate repeated symbols into <defs>. Point markers, hatch fills, and gradients are frequently emitted inline once per feature. Define each once in <defs> and reference it with <use>, replacing thousands of duplicated path strings with short references.
  4. Minify and compress. Strip editor metadata, comments, and default attributes with scour or svgo, then serve the result with Brotli so the wire payload is a fraction of the on-disk size.
Where the bytes actually go in an exported map SVG A waterfall of six optimisation steps applied in order to a 41 megabyte export. Rounding coordinates to six decimals removes 17 megabytes. Collapsing repeated style attributes into classes removes 6. Dropping editor metadata removes 3. Merging identical paths removes 2. Simplifying geometry to the delivery zoom removes 8. Gzip on the wire removes a further 3, leaving 5 megabytes served. 41 MB export 24 round coords 18 classes 15 metadata 13 dedupe 5 simplify 2 gzip Two steps account for 25 of the 39 megabytes removed, and neither changes what is drawn. Do those first and measure again before reaching for anything lossy.
Coordinate precision and repeated style attributes dominate an exported map SVG. Both are mechanical to fix, which is why geometry simplification should be the last resort rather than the first move.

Production-Ready Python Implementation

The pipeline below rounds coordinates and deduplicates markers in Python using lxml, then shells out to scour for final minification. It operates on an already-exported SVG, so it slots in after matplotlib.pyplot.savefig("map.svg") or a GDAL/OGR SVG export without changing the rendering step.

"""
svg_optimize.py — shrink an exported map SVG for web delivery.

Dependencies (pinned):
    lxml==5.2.1
    scour==0.38.2        # pip install scour ; CLI + library

Pipeline: round path coordinates -> deduplicate <use> targets -> scour minify.
"""
import re
import subprocess
from pathlib import Path

from lxml import etree

SVG_NS = "http://www.w3.org/2000/svg"
NS = {"svg": SVG_NS}

# Match signed decimal numbers (coordinates) inside path "d" attributes.
_NUM = re.compile(r"-?\d+\.?\d*(?:[eE][-+]?\d+)?")


def round_path_data(d: str, decimals: int = 2) -> str:
    """Round every numeric token in a path 'd' string to `decimals` places.

    Precision beyond output-pixel resolution is pure byte bloat. In projected
    metres (EPSG:3857) 2 decimals resolves to 1 cm, well below one screen pixel
    at any web zoom, so 1-2 decimals is almost always enough.
    """
    def _round(match: re.Match) -> str:
        value = round(float(match.group()), decimals)
        # Drop the trailing ".0" that float->str would otherwise emit.
        text = f"{value:.{decimals}f}".rstrip("0").rstrip(".")
        return text or "0"

    return _NUM.sub(_round, d)


def optimize_tree(svg_bytes: bytes, decimals: int = 2) -> bytes:
    """Round coordinates and strip editor metadata in-memory via lxml."""
    parser = etree.XMLParser(remove_blank_text=True, remove_comments=True)
    root = etree.fromstring(svg_bytes, parser=parser)

    # 1. Round coordinates on every <path d="...">.
    for path in root.iter(f"{{{SVG_NS}}}path"):
        d = path.get("d")
        if d:
            path.set("d", round_path_data(d, decimals))

    # 2. Drop editor cruft (Inkscape/Illustrator namespaces, <metadata>, <title>
    #    on interior nodes) that adds bytes with no rendering effect.
    for tag in ("metadata", "desc"):
        for el in root.iter(f"{{{SVG_NS}}}{tag}"):
            el.getparent().remove(el)

    return etree.tostring(root)


def scour_minify(svg_bytes: bytes, protect_prefix: str | None = None) -> bytes:
    """Run scour for final minification: relative paths, default-attr removal,
    whitespace collapse. Protect interactive ids from stripping when needed.
    """
    args = [
        "scour",
        "--enable-comment-stripping",
        "--enable-viewboxing",
        "--remove-descriptive-elements",
        "--create-groups",
        "--set-precision=5",          # scour's own coordinate precision guard
        "--indent=none",
    ]
    if protect_prefix:
        # Keep ids referenced by CSS/JS or <use>; see the scour FAQ note below.
        args.append(f"--protect-ids-prefix={protect_prefix}")
    else:
        args.append("--enable-id-stripping")

    result = subprocess.run(
        args, input=svg_bytes, capture_output=True, check=True
    )
    return result.stdout


def optimize_svg(src: Path, dst: Path, decimals: int = 2,
                 protect_prefix: str | None = None) -> tuple[int, int]:
    """Full pipeline. Returns (input_bytes, output_bytes)."""
    raw = src.read_bytes()
    rounded = optimize_tree(raw, decimals=decimals)
    minified = scour_minify(rounded, protect_prefix=protect_prefix)
    dst.write_bytes(minified)
    return len(raw), len(minified)


if __name__ == "__main__":
    before, after = optimize_svg(
        Path("map_export.svg"),
        Path("map_web.svg"),
        decimals=2,
        protect_prefix="ix-",   # ids like id="ix-region-42" survive stripping
    )
    ratio = 100 * (1 - after / before)
    print(f"{before:,} B -> {after:,} B  ({ratio:.1f}% smaller, pre-compression)")

To confirm what actually crosses the network, measure the Brotli-compressed size rather than the on-disk size, since SVG is text and compresses heavily:

import brotli   # pip install Brotli==1.1.0
from pathlib import Path

data = Path("map_web.svg").read_bytes()
wire = brotli.compress(data, quality=11)
print(f"on disk: {len(data):,} B   over Brotli: {len(wire):,} B")

Performance Tuning and Cartographic Best Practices

  • Simplify before you round, not after. Simplification decides which vertices exist; rounding decides how many bytes each surviving vertex costs. Running them in the wrong order wastes precision reduction on vertices you were about to delete. Pick the simplification tolerance from the export scale so no vertex is finer than a single output pixel.
  • Set precision from the coordinate system, not habit. In projected metres, 2 decimals is 1 cm resolution — already invisible on screen. If you export in a viewBox-local user-space unit, 1 decimal is often enough. Over-precision is usually the biggest line item in the byte breakdown, so audit it first.
  • Deduplicate symbols aggressively. Repeated point markers, north arrows, and hatch patterns should live once in <defs> and be referenced with <use>. A basemap with 10,000 identical station markers drops from thousands of duplicated path strings to one definition plus short references, which also parses faster in the browser.
  • Prefer entropy-removing levers over cosmetic ones. Whitespace stripping looks dramatic on the uncompressed number but gzip and Brotli already collapse whitespace, so the post-compression gain is small. Simplification and precision reduction remove real information a compressor cannot reconstruct, so they shrink the wire payload too.
  • Serve with Brotli quality 11 and a long cache TTL. Static basemap SVGs are immutable build artifacts, so compress once at the highest quality and cache hard. This mirrors the pre-generation philosophy used in Vector Tile Generation, where the expensive work happens once at build time rather than per request.
When an SVG stops being the right delivery format A decision path on feature count and interactivity. Under about five thousand features with per-feature interaction, inline SVG is right. Under fifty thousand without interaction, an image tag referencing the SVG avoids the DOM cost. Above that, vector tiles are the correct format because the browser cannot keep tens of thousands of nodes responsive regardless of file size. < 5 000 features and you need hover or click per feature inline SVG — every feature is a DOM node you can bind to < 50 000 features, no per-feature interaction <img src="map.svg"> — rendered by the image pipeline, no DOM cost beyond that — vector tiles, not a bigger SVG the limit is layout and hit-testing cost, which no compression touches A 2 MB SVG with 80 000 nodes downloads quickly and then freezes the tab for six seconds.
File size and rendering cost are separate limits, and only the first one responds to optimisation. Past roughly fifty thousand features the format is the problem.

Integration and Next Steps

This optimizer is a post-export stage, so it composes with any rendering front end. Wire it into a batch export loop after savefig, and gate the build on a size budget so a regression in the source geometry cannot silently ship a bloated map:

from pathlib import Path

BUDGET_KB = 300
for src in Path("exports").glob("*.svg"):
    _, after = optimize_svg(src, Path("dist") / src.name, decimals=2)
    assert after <= BUDGET_KB * 1024, f"{src.name} exceeds {BUDGET_KB} KB budget"

Precision choices here are the mirror image of the resolution decisions made for raster and print output; the trade-off between visual fidelity and file weight is developed further in DPI and Resolution Management. When your export path also targets print, the transparency-handling step interacts with vector output structure — see Flattening Transparency for PDF/X-4 Compliance for the print-side counterpart to this web-side optimization. Both are children of the broader High-Resolution Vector Export workflow, which frames when vector delivery beats raster in the first place.


Frequently Asked Questions

How many coordinate decimals should I keep for a web map SVG?

Match precision to the output resolution, not the source data. In projected metres (EPSG:3857), 2 decimals resolves to 1 cm on the ground, far below one screen pixel at any web zoom, so 1-2 decimals is almost always sufficient. In an unprojected user-space coordinate system sized to the viewBox, keep 1-2 decimals. Every extra digit adds roughly one byte per coordinate, and a dense basemap has millions of coordinates, so over-precision is typically the largest single contributor to file size.

Does minifying an SVG matter if I already serve it with gzip or Brotli?

Yes, but less than the raw numbers suggest. Compression removes most redundant whitespace and repeated tokens, so whitespace stripping alone gains little after gzip. However, coordinate precision reduction and geometry simplification remove actual entropy that no compressor can recover, so they shrink the compressed payload too. The practical rule: simplify and round first (these help even after Brotli), then minify for the smaller uncompressed size that decodes and parses faster in the browser.

Why does scour break my map when I enable --enable-id-stripping?

Scour’s id stripping removes any id it judges unreferenced, but it only tracks references it understands. If your map uses ids from external CSS, from JavaScript interaction handlers, or from <use xlink:href> targets that scour cannot resolve, those elements lose their ids and the references dangle. Use --protect-ids-prefix or --protect-ids-list to whitelist interactive ids, or disable id stripping for interactive maps and keep it only for static basemap tiles.


Back to High-Resolution Vector Export