Converting WKT to Protobuf for Low-Latency Routing

To convert WKT to Protobuf for low-latency routing, parse the Well-Known Text with shapely, repair its topology with make_valid, flatten the coordinates into a single repeated double array, and serialize that into a flat proto3 message — this drops payload size 60-85% and pushes deserialization into sub-millisecond ranges. This page sits under Geometry Validation Pipelines, part of the broader Spatial Payload Routing & Parsing architecture.

WKT’s string-heavy format forces token-based parsing at every network hop, adding CPU overhead, memory-allocation spikes, and unpredictable tail latency. Protobuf replaces that with fixed-width binary fields and schema-driven decoding, so downstream consumers skip geometry reconstruction until a spatial predicate actually requires it.

When to use this pattern

  • High-frequency dispatch: You route thousands of geometry updates per second and text parsing has become the measurable bottleneck in your webhook fan-out.
  • Predicate-light fast paths: Most incoming geometries are filtered by a cheap bounding-box check, so you want coordinates available as a contiguous buffer before any topology reconstruction.
  • Cross-language consumers: Producers and consumers run in different languages and you need a strict, versioned wire contract instead of relying on every service parsing WKT identically.

If your consumers stay in one language and need full topology on every message, the conversion overhead may not pay off — a shapely object passed in-process is simpler. For mapping a richer GeoJSON document to a typed message instead of bare WKT, see GeoJSON to Protobuf Mapping.

Conversion data flow

WKT to Protobuf conversion pipeline A WKT string is parsed and validated by shapely, flattened into a single coordinate array, serialized into a flat proto3 SpatialRoute message, and published to the routing broker where a bounding-box fast path filters most messages before full reconstruction. WKT string "POLYGON(( ... ))" Parse + validate make_valid() Flatten coords [x, y, x, y, ...] Serialize SpatialRoute msg Routing broker AABB fast path Text on the wire ~12 KB / 500 verts Binary on the wire ~4 KB / 500 verts

Complete runnable conversion

The schema stays deliberately flat. Routing engines need bounding boxes, segment checks, and CRS context — not a full topology tree. Nesting geometry objects only adds decode latency and complicates schema evolution.

proto
// spatial_route.proto — compile with: protoc --python_out=. spatial_route.proto
syntax = "proto3";

message SpatialRoute {
  string route_id = 1;
  string geometry_type = 2;       // POINT, LINESTRING, POLYGON, MULTIPOLYGON
  repeated double coordinates = 3; // Flattened [x, y, x, y, ...]
  int32 srid = 4;                 // EPSG code, e.g. 4326 (WGS84)
  uint64 timestamp_ms = 5;
}

The Python pipeline below parses WKT with shapely, enforces valid topology before anything touches the wire, flattens coordinates, and serializes. It handles single geometries and multi-part collections uniformly with no placeholder branches.

python
import time
from typing import List
from shapely import wkt
from shapely.geometry.base import BaseGeometry
from shapely.validation import make_valid
import spatial_route_pb2  # Generated from protoc


def _flatten_coords(geom: BaseGeometry) -> List[float]:
    """Walk any geometry into a single [x, y, x, y, ...] list.

    We deliberately drop Z/M ordinates ([:2]) because routing predicates
    here are planar; keep them only if your fast path is 3D-aware.
    """
    gtype = geom.geom_type.upper()
    coords: List[float] = []

    if gtype == "POINT":
        coords = list(geom.coords[0][:2])
    elif gtype in ("LINESTRING", "MULTIPOINT"):
        for pt in geom.coords:
            coords.extend(pt[:2])
    elif gtype == "POLYGON":
        # Exterior ring only — interior holes are irrelevant to an
        # axis-aligned bounding box, which is all the fast path needs.
        for pt in geom.exterior.coords:
            coords.extend(pt[:2])
    elif gtype in ("MULTILINESTRING", "MULTIPOLYGON", "GEOMETRYCOLLECTION"):
        for part in geom.geoms:
            coords.extend(_flatten_coords(part))
    return coords


def wkt_to_protobuf(wkt_string: str, route_id: str, srid: int = 4326) -> bytes:
    """Parse WKT, repair topology, flatten, and serialize to Protobuf bytes."""
    try:
        geom = wkt.loads(wkt_string)
    except Exception as exc:  # shapely raises GEOSException / ValueError
        raise ValueError(f"Invalid WKT syntax: {exc}") from exc

    # Enforce valid topology BEFORE serialization. A bowtie polygon that
    # slips onto the wire fails silently in the consumer's predicate stage.
    if not geom.is_valid:
        geom = make_valid(geom)

    msg = spatial_route_pb2.SpatialRoute(
        route_id=route_id,
        geometry_type=geom.geom_type.upper(),
        coordinates=_flatten_coords(geom),
        srid=srid,  # EPSG:4326 by default — carry it, never assume it
        timestamp_ms=int(time.time() * 1000),
    )
    return msg.SerializeToString()


if __name__ == "__main__":
    payload = wkt_to_protobuf(
        "POLYGON((-122.5 37.7, -122.4 37.7, -122.4 37.8, -122.5 37.8, -122.5 37.7))",
        route_id="zone-sf-001",
    )
    print(f"serialized {len(payload)} bytes")

On the consumer side, decode only coordinates and geometry_type for an initial bounding-box filter, and reconstruct the full shapely geometry only when the cheap check passes:

python
import numpy as np
import spatial_route_pb2


def aabb_overlaps(payload: bytes, zone_minx, zone_miny, zone_maxx, zone_maxy) -> bool:
    """Cheap O(N) axis-aligned bounding-box test before any topology work."""
    msg = spatial_route_pb2.SpatialRoute()
    msg.ParseFromString(payload)

    xy = np.frombuffer(bytes(bytearray(  # contiguous buffer, no per-point objects
        np.asarray(msg.coordinates, dtype=np.float64).tobytes())), dtype=np.float64)
    xs, ys = xy[0::2], xy[1::2]
    return not (xs.max() < zone_minx or xs.min() > zone_maxx
                or ys.max() < zone_miny or ys.min() > zone_maxy)

Most incoming geometries are rejected by this math before any expensive intersects/contains evaluation begins, which is where the 70-90% CPU saving in high-traffic dispatch comes from. When the WKT arrives from an async webhook handler, keep the parse off the event loop using the offload pattern in Optimizing Async Geometry Parsing with asyncio.

Parameter reference

Argument / field Type Spatial constraint Default
wkt_string str Must be syntactically valid WKT; repaired if topology is invalid — (required)
route_id str Stable per-geometry key for downstream idempotency — (required)
srid int EPSG code; routing assumes planar math, so use a metric CRS for distance 4326 (WGS84)
coordinates (field 3) repeated double Flattened [x, y, ...]; pairs must be even-length empty
geometry_type (field 2) str One of POINT/LINESTRING/POLYGON/MULTIPOLYGON parsed value
timestamp_ms (field 5) uint64 Epoch milliseconds, used for ordering and TTL now()

Gotchas & spatial edge cases

  1. Precision loss on quantization. Rounding to 6 decimal places (~10 cm in EPSG:4326) saves bytes but is lossy. Never re-round an already-quantized value on a later hop — errors compound across the routing graph.
  2. Ring orientation is not preserved by flattening. A flat array discards exterior/interior winding. The fast path only needs the exterior ring for its bounding box, but if a consumer rebuilds a Polygon from the array it must re-impose right-hand-rule orientation per the RFC 7946 GeoJSON specification before any contains test.
  3. CRS mismatch on merge. If two producers emit different srid values, a consumer that merges geometries without reading the field will compute garbage bounding boxes. Normalize first, following CRS Normalization Strategies, and treat a missing srid as a hard reject, not a 4326 default.
  4. Validity after make_valid. make_valid can change the geometry type (a self-intersecting Polygon may become a MultiPolygon or GeometryCollection). Always re-read geom.geom_type after repair — that is why the example sets geometry_type from the repaired object, not the input string.
  5. Mixed dimensionality. WKT with Z/M ordinates (POINT Z (...)) breaks an even-pair assumption if you forget the [:2] slice. Decide explicitly whether the fast path is 2D or 3D and keep the slice consistent on both ends.
  6. Field-number stability. Protobuf backward compatibility relies on stable field numbers. Use reserved for deprecated tags and never reuse a number — a renumbered srid silently mis-decodes every in-flight message.

Minimal verification

Run this pytest to confirm a round trip survives serialization and that an invalid polygon is repaired before it reaches the wire:

python
import pytest
from shapely import wkt
from shapely.geometry import shape
import spatial_route_pb2
from converter import wkt_to_protobuf  # the module above


def test_roundtrip_preserves_bounds():
    src = "LINESTRING(0 0, 1 1, 2 0)"
    payload = wkt_to_protobuf(src, route_id="t1", srid=4326)

    msg = spatial_route_pb2.SpatialRoute()
    msg.ParseFromString(payload)

    assert msg.srid == 4326
    assert msg.geometry_type == "LINESTRING"
    # Flattened pairs must round-trip exactly (no quantization here)
    assert list(msg.coordinates) == [0.0, 0.0, 1.0, 1.0, 2.0, 0.0]


def test_invalid_polygon_is_repaired():
    bowtie = "POLYGON((0 0, 1 1, 1 0, 0 1, 0 0))"  # self-intersecting
    payload = wkt_to_protobuf(bowtie, route_id="t2")

    msg = spatial_route_pb2.SpatialRoute()
    msg.ParseFromString(payload)
    # make_valid may promote POLYGON -> MULTIPOLYGON; both are acceptable
    assert "POLYGON" in msg.geometry_type
    assert len(msg.coordinates) % 2 == 0


def test_bad_wkt_raises():
    with pytest.raises(ValueError):
        wkt_to_protobuf("NOT WKT", route_id="t3")