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 is a text format, and every hop pays to read it A 41,000-vertex polygon expressed as WKT is a single string in which every coordinate is decimal text. Reading it means scanning the string and converting each number, costing about 46 milliseconds, and a routing graph with four hops that each need the geometry pays that four times, for roughly 184 milliseconds of pure parsing. Converting once at ingest into a Protobuf message with packed doubles costs the same 46-millisecond parse plus about 2 milliseconds to encode, after which each hop decodes in about 1.9 milliseconds because the coordinates are already binary and are read by memory copy rather than by numeric conversion. Total parsing across the graph falls to about 54 milliseconds. WKT remains the right format at the boundary, because it is what spatial databases emit and what a human can read in a log — the point is that it should be crossed once, at the edge, rather than carried through the routing layer as the transport format. WKT carried through the routing graph parse 46 ms parse 46 ms parse 46 ms parse 46 ms 4 hops 184 ms of the event's journey spent converting decimal text back into numbers Converted once at the boundary parse 46 + encode 2 ms 1.9 1.9 1.9 54 ms total — packed doubles are read by memory copy, not numeric conversion WKT is still the right format at the boundary It is what spatial databases emit and what a human can read in a log. The point is to cross it once, at the edge — not to carry it as the transport format through a routing layer that reads it repeatedly.
Figure 1. The conversion is not free, which is exactly why it should happen once. Text formats charge every reader; binary ones charge the writer.
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
Figure 2. WKT to Protobuf conversion pipeline

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.
Re-rounding compounds; rounding once does not A coordinate of 13.4049547 passes through four routing hops. If each hop quantises to six decimal places, the first rounds to 13.404955, which is fine, but a hop that rounds a value already sitting on a boundary can push it a further half-unit each time, so after four hops the accumulated displacement reaches roughly 40 centimetres and the direction of drift depends on which hops happened to see the value — meaning the same feature routed by two different paths ends up at two different positions. Quantising exactly once, at ingest, and treating the value as already canonical downstream keeps the error at the single 5-centimetre half-step the rounding was chosen to accept. The practical rule is that quantisation is a normalisation step, not a compression step: it belongs with the other ingest-time normalisations that happen once, and every downstream hop should be able to assume it has already run. Every hop quantises 13.4049547 13.404955 13.404956 13.404958 ≈ 40 cm off And the drift direction depends on which hops saw the value — so two routing paths place the same feature differently. Quantise once at ingest, then treat as canonical 13.4049547 13.404955 hops 2–4 pass it through unchanged ≈ 5 cm, once Quantisation is a normalisation step, not a compression step It belongs with the other ingest-time normalisations that run exactly once, and every downstream hop should be free to assume it already has.
Figure 3. The half-step you accept when choosing a precision is a one-time cost only if the rounding happens once. Re-rounding turns a bounded error into one that grows with the length of the routing path.

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")