This guide is part of Handling Precision & Coordinate Rounding, which sits within the Automated Vector & Raster Cleaning Workflows reference for building reliable spatial ETL pipelines.

Reducing Coordinate Precision to Shrink GeoJSON

The Problem: 15-Digit Coordinates Bloat Every Web Payload

A GeoDataFrame exported straight to GeoJSON writes every coordinate at full float precision — commonly 15 significant digits — even though the underlying survey resolved position to a metre. That surplus precision is dead weight the browser downloads, parses, and holds in memory for no accuracy benefit whatsoever.

The arithmetic is stark: -73.985428193746132 is 18 characters, while -73.985428 is 10 and locates the same point to within 0.11 m. Across a polygon layer with millions of vertices, trimming to six decimal places routinely cuts the payload by a third or more before any transport compression. Unlike the metric grid-snapping used for topology work, this is the one case where you deliberately round in decimal degrees, because GeoJSON per RFC 7946 mandates WGS84 and the target metric is byte size, not planar tolerance.

  • Slow first paint: an oversized GeoJSON blocks the map render until the whole file is transferred and parsed on the client.
  • Wasted memory on the client: every excess digit is retained in the parsed coordinate arrays a mobile browser must hold.
  • No accuracy gained: digits beyond the instrument’s resolution encode reprojection and float noise, not real position.
  • Rounding the wrong way keeps the digits: applying round() to values in the GeoJSON dictionary can still serialize trailing binary noise, so the file never shrinks.

Version and Environment Compatibility

Shapely GeoPandas Output path Caveat
≥ 2.0 ≥ 0.13 shapely.set_precision(arr, grid) then to_json Preferred; float shortest-repr emits short numbers
2.0.x 0.12 set_precision + GeoDataFrame.to_json Confirm output is EPSG:4326 before serialising
≥ 2.0 ≥ 0.14 to_file(..., COORDINATE_PRECISION=6, engine='pyogrio') GDAL enforces textual precision at write time

Install the recommended stack:

pip install "geopandas>=0.13.0" "shapely>=2.0.0" pyogrio

Decimal Places, Ground Resolution, and Payload Size

Decimal places versus ground resolution and payload size A horizontal scale marks four to seven decimal places. Each tick is annotated with its approximate ground resolution at the equator. The six-decimal position is highlighted as the recommended default for web maps, and an arrow indicates that payload size increases as more decimals are retained. web-safe default 4 dp ~11 m 5 dp ~1.1 m 6 dp ~0.11 m 7 dp ~1.1 cm payload size grows with retained decimals →

Each decimal place is roughly a factor of ten in ground resolution and adds one character per coordinate axis. The rule of thumb at the equator: four places locate a parcel to street width, six to a doorway, seven to a survey pin. For web delivery, six is the near-universal sweet spot — finer than the eye can distinguish at any practical zoom, yet meaningfully smaller than a full-precision export.

Recipe: shrink_geojson_precision

The function reprojects to WGS84, snaps to a decimal-degree grid with the vectorized set_precision, repairs any geometry the snap invalidated, and reports the byte savings against a full-precision baseline.

import logging

import geopandas as gpd
import shapely

logger = logging.getLogger(__name__)


def shrink_geojson_precision(
    gdf: gpd.GeoDataFrame,
    decimals: int = 6,
    path: str | None = None,
) -> tuple[str, dict]:
    """
    Reduce coordinate precision to shrink a GeoJSON payload and report savings.

    GeoJSON (RFC 7946) mandates WGS84 longitude/latitude, so — unlike topology
    work — precision here is measured in decimal degrees. Six decimals resolve
    about 0.11 m at the equator, finer than most web-delivered data needs, yet
    full-precision exports carry ~15 digits of float noise. Snapping to a
    10**-decimals grid with the Shapely 2.0 vectorized set_precision collapses
    that noise so the JSON serialiser emits short numbers.

    Parameters
    ----------
    gdf : GeoDataFrame
        Any CRS; reprojected to EPSG:4326 for RFC 7946 compliance.
    decimals : int
        Retained decimal places. 6 ~ 0.11 m, 5 ~ 1.1 m, 7 ~ 1.1 cm at equator.
    path : str | None
        When given, the reduced GeoJSON is written to this path.

    Returns
    -------
    (str, dict)
        The reduced GeoJSON string and a metrics dict including byte savings.
    """
    if decimals < 0:
        raise ValueError("decimals must be >= 0")
    if gdf.crs is None:
        raise ValueError("Assign a CRS before exporting GeoJSON.")

    # RFC 7946 requires WGS84; reproject only if needed.
    if gdf.crs.to_epsg() != 4326:
        gdf = gdf.to_crs(4326)

    # Baseline: full-precision GeoJSON for the byte comparison.
    full = gdf.to_json()
    full_bytes = len(full.encode("utf-8"))

    # Snap to a decimal-degree grid in one vectorized GEOS call.
    grid = 10 ** (-decimals)
    reduced = gdf.copy()
    reduced.geometry = shapely.set_precision(gdf.geometry.values, grid_size=grid)

    # A degree-grid snap can pinch a polygon; repair so the payload stays valid.
    invalid = ~shapely.is_valid(reduced.geometry.values)
    if invalid.any():
        logger.warning("%d geometries invalid after snap — repairing", int(invalid.sum()))
        reduced.geometry = shapely.make_valid(reduced.geometry.values)

    text = reduced.to_json()
    reduced_bytes = len(text.encode("utf-8"))

    if path is not None:
        with open(path, "w", encoding="utf-8") as fh:
            fh.write(text)

    metrics = {
        "features": len(gdf),
        "decimals": decimals,
        "ground_resolution_m": round(111_320 * grid, 4),
        "bytes_full": full_bytes,
        "bytes_reduced": reduced_bytes,
        "bytes_saved": full_bytes - reduced_bytes,
        "pct_saved": round((1 - reduced_bytes / full_bytes) * 100, 1) if full_bytes else 0.0,
    }
    logger.info("shrink_geojson_precision: %s", metrics)
    return text, metrics

Key Implementation Notes

  • set_precision shortens the text, not just the value. Snapping to 10**-decimals moves each coordinate to the nearest grid multiple; the closest 64-bit double to that multiple has a short shortest-round-trip representation, so Python’s JSON encoder emits -73.985428 rather than a 15-digit string. Rounding the dictionary values with round() does not guarantee this and is the usual reason files stay large.
  • Reproject to EPSG:4326 unconditionally at the export boundary. RFC 7946 forbids any CRS other than WGS84 and disallows the legacy crs member. Exporting a projected frame produces coordinates in metres that strict readers reject as out-of-range longitude/latitude.
  • The degree-grid snap can still invalidate polygons. At five or fewer decimals, adjacent polygon vertices can snap to the same node and pinch a self-touch. The make_valid repair keeps the payload loadable; for layers where shared borders matter, keep six-plus decimals or simplify with a topology-aware tool instead.
  • Measure savings after transport compression too. The pct_saved here is the raw-byte reduction. Gzip already exploits repeated digit runs, so the gain over an already-compressed response is smaller — report both when justifying the change.
  • This is a write-time step, not a processing step. Snap in a metric CRS for all in-pipeline topology work, as covered in snapping coordinates to a grid with set_precision, and apply degree-based trimming only when serialising the final GeoJSON for delivery.

Troubleshooting GeoJSON Precision Reduction

Symptom Root Cause Fix
Reduced file barely smaller Source already low-precision, or gzip applied first Measure before transport compression; gzip captures most remaining gains
Polygons develop gaps at low decimals Shared borders snapped in degrees to different nodes Keep six-plus decimals for polygon layers, or simplify with a topology-aware tool
Coordinates still print 15 digits Rounded with Python round() on the dict, not set_precision Snap geometries with set_precision so the float repr shortens
Output rejected by a strict GeoJSON reader CRS was not WGS84 or carried a crs member Reproject to EPSG:4326; RFC 7946 forbids non-WGS84 and named CRS members
Visible position shift on a base map Decimals too aggressive for the zoom level Raise decimals: six for street level, seven for survey overlays

Integration Note

Run shrink_geojson_precision at the very end of the pipeline, in the Load stage, only on the payload destined for a browser or API — never on the canonical stored dataset, which should retain full precision. It closes the workflow in handling precision and coordinate rounding and complements snapping coordinates to a grid with set_precision: metric snapping fixes topology during processing, degree trimming shrinks bytes at delivery. When payload size is the real constraint, also weigh a binary format over GeoJSON entirely — converting shapefiles to GeoParquet with GeoPandas covers the columnar alternative that beats trimmed GeoJSON on both size and read speed for analytical loads.