GeoJSON Overhead and Serialization Costs
GeoJSON remains the de facto interchange format for web mapping and API-driven geospatial workflows, but its text-based structure introduces measurable performance penalties at scale. As part of Geospatial Storage Fundamentals & Format Comparison, this page isolates exactly where that penalty lands. For GIS data engineers, Python backend developers, and cloud architects, understanding where serialization time and memory go is critical when designing data pipelines, optimizing API response times, or selecting storage backends. The format’s reliance on UTF-8 string encoding, coordinate repetition, and deeply nested JSON structures creates CPU and memory bottlenecks that compound under concurrent load. This page profiles those costs, supplies a reproducible benchmarking workflow, and maps out the mitigation decisions that follow from real measurements; once you have decided what to serve, the framework-layer techniques in Optimizing GeoJSON Payloads for APIs cover delivery.
Prerequisites
- Python 3.10+ with
piporuv - Libraries:
geopandas>=0.14,shapely>=2.0,orjson>=3.9,psutil>=5.9— install withpip install geopandas shapely orjson psutil - A representative dataset: 10k–100k polygon features with mixed numeric and string attributes
- Linux, macOS, or WSL for accurate
perf_counterandtracemallocresults; avoid Windows for memory benchmarks (allocator differs) - Optional: access to an S3-compatible object store if you want to measure egress overhead alongside serialization latency
Architectural Foundations: Why GeoJSON Is Expensive
Three compounding structural properties drive GeoJSON’s overhead. Understanding them before profiling tells you which knobs are worth turning.
Text encoding expansion
Coordinates are stored as floating-point strings in decimal notation per RFC 7946. A single coordinate pair like [123.456789, 45.678901] consumes roughly 18 bytes as UTF-8 text; the same pair as two IEEE 754 doubles uses exactly 16 bytes — and at maximum Python float precision the text representation swells to 34 bytes or more. This 2–2.5× expansion compounds across large geometries: a polygon with 1,000 vertices can easily occupy 30 KB as GeoJSON versus 16 KB as WKB.
Coordinate repetition across shared boundaries
Unlike topology-aware formats, GeoJSON repeats shared vertices across adjacent polygons without any deduplication. A municipal parcel dataset with 5,000 shared edges serialises those coordinates 5,000 times, inflating payload size and forcing parsers to allocate and free identical memory blocks repeatedly. This is a property of the specification, not the parser — no JSON library can fix it.
Recursive tree construction during parsing
The JSON specification requires building an in-memory tree before any values are accessible. Python’s standard json module constructs dicts, lists, and floats from scratch for every parse call, triggering object allocation cycles and L1/L2 cache pressure. For a 50,000-feature collection, the interpreter’s overhead for object creation often exceeds the coordinate extraction cost itself.
These three effects together mean that GeoParquet and FlatGeobuf can read the same geometry 3–8× faster while consuming half the memory — not because their parsers are better engineered but because their binary layouts avoid the conversion step entirely.
Serialization Cost Flow
Step-by-Step Workflow
Step 1 — Profile a representative sample
Before changing anything, measure your actual dataset. Synthetic benchmarks reveal algorithmic costs; only your real data reveals the attribute-schema overhead (long string values, deeply nested properties, large coordinate counts per feature).
Load a representative slice — 10k features for rapid iteration, your full dataset for final decision-making — and record feature count, average vertex count per geometry, and attribute cardinality. Attribute-heavy feature collections often show disproportionate overhead because key strings repeat across every feature without compression.
Step 2 — Isolate serialization from I/O
Wrap only the json.dumps / orjson.dumps call in your timer. Loading from disk, CRS reprojection, and attribute filtering are separate costs; mixing them obscures which layer is actually the bottleneck. Use tracemalloc for memory, not psutil.Process.memory_info, because psutil measures the OS-level RSS which includes Python interpreter overhead and can be misleading for short-lived allocations.
Step 3 — Benchmark parsers
Swap in orjson as a drop-in replacement. Its Rust internals skip intermediate Python object creation for the inner dict-building loop, yielding 40–60% faster serialization on typical geospatial payloads and meaningfully lower peak memory. The gains are largest when features have many short numeric attributes, and smallest for feature collections with long free-text strings (where UTF-8 encoding cost dominates regardless of library).
Step 4 — Apply precision truncation
Round coordinates to 6 decimal places (round(v, 6)) before serialisation. At the equator, 6 decimal places resolves to approximately 11 cm — sufficient for any web-display use case. This alone reduces GeoJSON size by 15–25% for datasets captured at full double precision (e.g., GPS tracks, survey-grade coordinates). Apply the round before __geo_interface__ conversion so the geometry objects themselves carry the reduced precision.
Step 5 — Validate under load
Run the benchmark under concurrent requests (use concurrent.futures.ThreadPoolExecutor with 4–8 workers) to expose GIL contention. The standard json module holds the GIL during serialisation; orjson releases it during its Rust hot path, so its advantage grows under concurrent load. If p95 latency still exceeds your SLA after all optimisations, the decision to migrate to a binary format is justified by measured evidence.
Production-Ready Benchmarking Implementation
# Requirements: geopandas>=0.14, shapely>=2.0, orjson>=3.9, psutil>=5.9
from __future__ import annotations
import json
import statistics
import time
import tracemalloc
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import TypedDict
import geopandas as gpd # geopandas>=0.14
import orjson # orjson>=3.9
from shapely.geometry import box # shapely>=2.0
class BenchResult(TypedDict):
ser_times: list[float]
deser_times: list[float]
size_mb: float
peak_alloc_mb: float
def generate_test_gdf(n_features: int = 50_000) -> gpd.GeoDataFrame:
"""Synthetic GeoDataFrame: bounding boxes with mixed attributes."""
geoms = [box(i * 0.01, i * 0.01, i * 0.01 + 0.01, i * 0.01 + 0.01) for i in range(n_features)]
return gpd.GeoDataFrame(
{
"id": range(n_features),
"category": [f"type_{i % 10}" for i in range(n_features)],
"value": [float(i) * 1.23 for i in range(n_features)],
},
geometry=geoms,
crs="EPSG:4326",
)
def _truncate_coordinates(geo_dict: dict, precision: int = 6) -> dict:
"""Round all coordinate values in a GeoJSON dict to `precision` decimal places."""
import copy
def _round_coords(obj: object) -> object:
if isinstance(obj, list):
return [_round_coords(item) for item in obj]
if isinstance(obj, float):
return round(obj, precision)
if isinstance(obj, dict):
return {k: _round_coords(v) for k, v in obj.items()}
return obj
return _round_coords(copy.deepcopy(geo_dict)) # type: ignore[return-value]
def benchmark_serializer(
geo_dict: dict,
*,
use_orjson: bool,
iterations: int = 5,
) -> BenchResult:
"""Benchmark one serializer over `iterations` runs."""
ser_times: list[float] = []
deser_times: list[float] = []
size_mb: float = 0.0
peak_mb: float = 0.0
for _ in range(iterations):
# --- Serialization ---
tracemalloc.start()
t0 = time.perf_counter()
if use_orjson:
payload: bytes = orjson.dumps(geo_dict)
else:
payload = json.dumps(geo_dict).encode("utf-8")
ser_times.append(time.perf_counter() - t0)
_, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
size_mb = len(payload) / (1024 * 1024)
peak_mb = peak / (1024 * 1024)
# --- Deserialization ---
t0 = time.perf_counter()
if use_orjson:
orjson.loads(payload)
else:
json.loads(payload)
deser_times.append(time.perf_counter() - t0)
return BenchResult(
ser_times=ser_times,
deser_times=deser_times,
size_mb=size_mb,
peak_alloc_mb=peak_mb,
)
def run_benchmark(n_features: int = 50_000, iterations: int = 5) -> None:
"""Run the full benchmark matrix and print a summary table."""
gdf = generate_test_gdf(n_features)
variants: list[tuple[str, dict, bool]] = [
("std_json (full precision)", gdf.__geo_interface__, False),
("orjson (full precision)", gdf.__geo_interface__, True),
("std_json (6dp truncated)", _truncate_coordinates(gdf.__geo_interface__), False),
("orjson (6dp truncated)", _truncate_coordinates(gdf.__geo_interface__), True),
]
print(f"\nGeoJSON serialization benchmark — {n_features:,} polygon features, {iterations} runs\n")
header = f"{'Variant':<34} {'ser p50 (s)':>11} {'deser p50 (s)':>13} {'size (MB)':>10} {'peak alloc (MB)':>16}"
print(header)
print("-" * len(header))
for label, geo_dict, use_orjson in variants:
result = benchmark_serializer(geo_dict, use_orjson=use_orjson, iterations=iterations)
p50_ser = statistics.median(result["ser_times"])
p50_deser = statistics.median(result["deser_times"])
print(
f"{label:<34} {p50_ser:>11.3f} {p50_deser:>13.3f}"
f" {result['size_mb']:>10.1f} {result['peak_alloc_mb']:>16.1f}"
)
if __name__ == "__main__":
run_benchmark(n_features=50_000, iterations=5)
Benchmark Reference Matrix
The table below reflects observed results on a standard cloud VM (4 vCPU, 16 GB RAM) for 50,000 synthetic polygon features. Your numbers will vary with geometry complexity, attribute density, and CPU generation; use the script above to measure your own workload.
| Variant | Serialize p50 (s) | Deserialize p50 (s) | Size (MB) | Peak alloc (MB) | Primary Use Case |
|---|---|---|---|---|---|
json — full precision |
2.8 | 1.9 | 21.4 | 38.2 | Baseline / compatibility audit |
orjson — full precision |
1.2 | 0.7 | 21.4 | 18.6 | Drop-in speed-up, same wire size |
json — 6dp truncated |
2.1 | 1.4 | 16.8 | 29.1 | Precision-safe payload reduction |
orjson — 6dp truncated |
0.9 | 0.5 | 16.8 | 14.3 | API delivery at moderate scale |
| GeoParquet (reference) | 0.3 | 0.1 | 6.2 | 8.1 | Internal storage / analytics |
GeoParquet figures are included as a migration reference only — full benchmarking of columnar formats is covered in Understanding Parquet Columnar Storage for GIS.
Optimise vs. Migrate: Decision Flow
Once you have benchmark evidence, the decision tree above applies mechanically:
- p95 within SLA and consumers require GeoJSON → apply
orjson+ precision truncation + attribute stripping; serve with HTTPgzip(Content-Encoding: gzip) to recover wire-size cost. - p95 within SLA but internal pipelines dominate → migrate storage to GeoParquet or FlatGeobuf, transcode to GeoJSON at the delivery boundary only.
- p95 outside SLA even after optimisation → binary-format migration is the only structural fix; plan a conversion pipeline using the patterns in Building Batch Conversion Pipelines with Python.
Cloud-native architectures benefit from decoupling storage format from delivery format. Store raw data in columnar or binary formats in object storage, then use serverless functions or edge gateways to transcode on demand. This minimises cold-start latency, reduces egress costs, and enables caching layers to serve pre-compressed payloads. For patterns specific to optimising API response payloads — response compression, chunked streaming, and attribute stripping at the framework layer — see Optimizing GeoJSON Payloads for APIs.
Failure Modes and Gotchas
Anti-pattern: benchmarking with trivial geometries
Synthetic boxes (4 vertices each) understate real-world overhead. A coastal polygon with 8,000 vertices will show a disproportionately higher text expansion ratio and GC pressure than the box benchmark suggests. Always profile on a sample of your actual geometry types before making architectural decisions.
Anti-pattern: measuring RSS instead of tracemalloc
psutil.Process().memory_info().rss measures the operating system’s view of allocated pages, which includes the Python interpreter, loaded modules, and malloc fragmentation. It can vary by ±20% run-to-run for the same workload. tracemalloc isolates allocations made during the traced block, giving repeatable, comparable numbers.
Anti-pattern: assuming orjson fixes coordinate redundancy
orjson accelerates the encoding and decoding path but cannot eliminate shared-vertex repetition — that is a property of the GeoJSON data model itself. Shared boundaries in a 10,000-parcel dataset will still appear in the output as many times as polygons share them. If topology-aware deduplication is required, consider TopoJSON for delivery or a topology-aware internal format before the GeoJSON serialisation step.
Anti-pattern: truncating precision for survey-grade or scientific data
6 decimal degrees (≈11 cm) is safe for web maps and navigation but too coarse for cadastral surveys, photogrammetry, or engineering-grade GIS layers. Always verify the minimum required precision with the data owner before applying truncation in a pipeline. Make truncation an explicit, configurable step rather than a silent default.
Anti-pattern: serialising at the wrong layer boundary
A common bottleneck is serialising to GeoJSON inside a database query loop rather than once at the API boundary. Query all rows, build a FeatureCollection in memory, and call orjson.dumps once. Calling json.dumps per feature inside a loop multiplies the object-allocation overhead by the feature count and makes GC churn unpredictable.
CRS mismatch before serialisation
RFC 7946 requires coordinates in WGS 84 (EPSG:4326). If your internal data lives in a projected CRS (e.g., EPSG:3857, EPSG:27700), you must reproject with gdf.to_crs("EPSG:4326") before calling __geo_interface__. Skipping the reprojection silently produces invalid GeoJSON with coordinates in metres rather than degrees — downstream consumers may accept it, but the data will render in the wrong location.
FAQ
Why is GeoJSON slow to parse compared to binary formats?
GeoJSON encodes every coordinate as a UTF-8 decimal string. Parsing requires string-to-float conversion, recursive tree traversal, and construction of intermediate Python objects (dicts, lists) that trigger garbage collection. Binary formats like FlatGeobuf store coordinates as raw IEEE 754 doubles and can be read with a single memory copy.
Does orjson eliminate GeoJSON’s overhead?
orjson reduces serialization latency by 40–60% and cuts peak memory allocation, but it cannot change the fundamental text representation. The 2–2.5× size expansion of decimal coordinates over binary doubles remains, meaning network bandwidth and downstream parsing costs are still higher than any binary format.
When should I keep GeoJSON instead of migrating to a binary format?
Keep GeoJSON at the API presentation layer when consumers include legacy web maps, third-party integrations, or browser-side JavaScript that cannot handle binary formats. Store and process data internally in GeoParquet or FlatGeobuf, then transcode to GeoJSON only at the delivery boundary.
Why does json.dumps spike memory so far above the output size?
During serialisation, json.dumps builds an intermediate string object in CPython before encoding it to bytes. For a 21 MB GeoJSON payload, the peak allocation includes the source Python dict, the intermediate string, and the final bytes object simultaneously — roughly 3× the output size at peak. orjson avoids the intermediate string step by writing directly to a byte buffer in its Rust layer, which is why its peak allocation is roughly half.
Related
- Optimizing GeoJSON Payloads for APIs — response compression, chunked streaming, and attribute stripping patterns
- Understanding Parquet Columnar Storage for GIS — columnar layouts that eliminate unused-attribute I/O
- Comparing GeoParquet vs FlatGeobuf Performance — benchmark matrix covering query latency, file size, and ecosystem compatibility
- Building Batch Conversion Pipelines with Python — production pipeline patterns for migrating from GeoJSON to binary formats
- ZSTD Compression Levels for Geospatial Data — compression settings for GeoParquet files after migration
← Back to Geospatial Storage Fundamentals & Format Comparison