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" pyogrioDecimal Places, Ground Resolution, and Payload Size
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, metricsKey Implementation Notes
set_precisionshortens the text, not just the value. Snapping to10**-decimalsmoves 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.985428rather than a 15-digit string. Rounding the dictionary values withround()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
crsmember. 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_validrepair 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_savedhere 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.
Related
- Handling Precision and Coordinate Rounding — the full precision-normalisation workflow this recipe closes out
- Snapping Coordinates to a Grid with set_precision — metric grid snapping for in-pipeline topology, the counterpart to degree trimming
- Converting Shapefiles to GeoParquet with GeoPandas — a binary output format that beats trimmed GeoJSON on size and read speed
- Automated Vector & Raster Cleaning Workflows — the parent reference for the full cleaning pipeline