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.

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.

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