Skip to content

Text & binary formats

gometry moves geometry in and out as WKB, EWKB, WKT, EWKT, GeoJSON, the __geo_interface__ protocol, and pickle. Each format carries CRS, Z, and M differently. Z is preserved unless you ask to drop it; M is preserved in formats that can hold it and rejected in formats that can't (rather than silently discarded); SRID is embedded only when you opt in.

Round-trip cheat sheet

Format CRS Coordinate epoch Z / M
ISO WKB / WKT Not stored Not stored Both preserved
EWKB / EWKT Integer SRID Not stored Both preserved
GeoJSON / __geo_interface__ WGS 84 lon/lat; no source CRS label Not stored Z preserved; M rejected
GeoArrow PROJJSON gometry extension metadata Both preserved
pandas Preserved Preserved Both preserved
GeoPandas Preserved Not stored Preserved where the peer supports them
Polars EWKB SRID, unless dropped Not stored Both preserved
Pickle Preserved Preserved Both preserved

Serializers that cannot store an epoch require drop_epoch=True when one is present. The __geo_interface__ property has no such option: clear the epoch explicitly before using it. GeoJSON also requires you to remove M explicitly. GeoArrow epoch preservation requires gometry or another consumer that understands its extension metadata. Pickle is for trusted Python data only.

WKB and EWKB

to_wkb writes ISO WKB by default — the portable, standards-based binary boundary. It does not embed an SRID. Pass include_srid=True to write PostGIS-style EWKB with the SRID encoded. EWKB is a PostGIS extension (not an OGC standard); use it with systems that explicitly speak PostGIS EWKB (PostGIS itself, and some drivers that document EWKB support). For a portable boundary, prefer ISO WKB plus CRS in side metadata (or GeoArrow).

import gometry as gm

p = gm.Point(21.0, 52.0, crs=4326)
iso = p.to_wkb()
ewkb = p.to_wkb(include_srid=True)
print('ISO WKB bytes:', len(iso))
print('EWKB bytes:', len(ewkb))
print('from ISO WKB crs:', gm.from_wkb(iso).crs)
print('from EWKB crs:', gm.from_wkb(ewkb).crs)
ISO WKB bytes: 21
EWKB bytes: 25
from ISO WKB crs: None
from EWKB crs: EPSG:4326

ISO WKB drops the CRS; EWKB carries an integer SRID

from_wkb(to_wkb(...)) returns geometry with no CRS — ISO WKB has nowhere to store one. EWKB can embed an integer EPSG SRID when the geometry's CRS has an EPSG authority code, or when it is one of the exact PostGIS wire aliases OGC:CRS84 → SRID 4326 and OGC:CRS84h → SRID 4979 (to_wkb(include_srid=True) rejects other non-EPSG CRS values). These aliases are a deliberate PostGIS serialization convention (lon/lat storage matches EPSG:4326 on the wire), not universal CRS identity: object equality CRS(4326) == CRS("OGC:CRS84") stays False, and a bare EWKB round-trip therefore loses the OGC spelling:

partial: requires the preceding WKB setup
from_wkb(Point(10, 50, crs="OGC:CRS84").to_wkb(include_srid=True)).crs

Pass crs="OGC:CRS84" at decode to restore the alias when the embedded SRID is exactly 4326 (a genuine SRID/CRS conflict still raises). Epoch never rides either WKB flavour.

The database boundary

The portable binary boundary is ISO WKB with CRS/epoch in side metadata, or GeoArrow with CRS plus gometry's epoch extension when the peer understands it. Use EWKB with PostGIS or another system that documents EWKB SRID support. gometry ships no database adapters. Keep the boundary binary and columnar — write bytes out, read bytes back, and decode the whole column in one gm.from_wkb call rather than a per-row geometry loop:

import sqlite3

import gometry as gm

geoms = gm.points([21.0, 22.0], [52.0, 53.0], crs=4326)
ewkb_rows = geoms.to_wkb(include_srid=True)

db = sqlite3.connect(":memory:")
db.execute("create table places (id integer primary key, geom blob not null)")
db.executemany(
    "insert into places (id, geom) values (?, ?)",
    enumerate(ewkb_rows, start=1),
)

payloads = [row[0] for row in db.execute("select geom from places order by id")]
restored = gm.from_wkb(payloads)
db.close()

print(restored)
print("crs:", restored.crs.to_authority())
print(restored.to_wkt())
<GeometryArray[Point] len=2 EPSG:4326>
crs: ('EPSG', '4326')
['POINT (21 52)', 'POINT (22 53)']

Use EWKB (include_srid=True) when talking to PostGIS-compatible storage that should embed an EPSG SRID. Use plain ISO WKB when the CRS lives in a side column or table contract, then attach it at ingress with gm.from_wkb(payloads, crs=...). For columnar handoff (DuckDB, Arrow IPC), prefer GeoArrow via to_arrow — including encoding='wkb' when a WKB Arrow column is the peer contract rather than a packed GeoArrow layout.

import gometry as gm

geoms = gm.points([21.0, 22.0], [52.0, 53.0], crs=4326)
wkb_arrow = geoms.to_arrow(encoding="wkb")
print(wkb_arrow.type)
extension<geoarrow.wkb<GeoArrowType>>

WKT and EWKT

WKT is the human-readable format. gometry reads and writes the Z, M, and ZM dimensional tags, preserving the ordinates the geometry carries. Pass include_srid=True to emit EWKT with an SRID= prefix so the CRS survives the text round-trip.

import gometry as gm

pz = gm.Point(21.0, 52.0, z=100.0, crs=4979)
print("default:", pz.to_wkt())
print("force 2D:", pz.force_2d().to_wkt())
print("EWKT:", pz.to_wkt(include_srid=True))

print("parse:", gm.from_wkt("POINT ZM (1 2 3 4)").coordinate_axes)
default: POINT Z (21 52 100)
force 2D: POINT (21 52)
EWKT: SRID=4979;POINT Z (21 52 100)
parse: XYZM

WKT is for humans, not throughput

Use WKT for debugging, logs, and copy-paste. For high-volume storage prefer WKB/EWKB or GeoArrow — parsing decimal text is far slower than reading packed binary.

GeoJSON

GeoJSON (RFC 7946) defines positions as [lon, lat] with an optional third altitude element. It has no place for M or coordinate epoch metadata. to_geojson and __geo_interface__ raise when those values cannot be represented.

GeoJSON uses OGC:CRS84 longitude/latitude order and carries no source CRS label. A tagged geometry must already be in the WGS 84 geographic family; serialization does not reproject it. A CRS-free geometry is an explicit assumption that its coordinates already have OGC:CRS84 semantics: output validates longitude in [-180, 180] and latitude in [-90, 90], and splits antimeridian-crossing geometry according to RFC 7946. CRS-free projected or other planar coordinates must be transformed or written with another format.

On read, the default crs='OGC:CRS84' records these semantics; pass crs=None only when you intentionally want to discard the CRS metadata.

An antimeridian-crossing line is written as separate pieces with corresponding vertices at 180 and -180. For XYZ input, each inserted seam vertex carries altitude from the original CRS84 edge by ellipsoidal arclength, not by the longitude/latitude crossing parameter; see Z/M under operations.

import json

import gometry as gm

crossing = gm.LineString([(170, 10, 10), (-170, 10, 30)], crs="OGC:CRS84")
geojson = json.loads(crossing.to_geojson())
print(geojson["type"])
print(geojson["coordinates"])
MultiLineString
[[[170.0, 10.0, 10.0], [180.0, 10.0, 20.0]], [[-180.0, 10.0, 20.0], [-170.0, 10.0, 30.0]]]
import gometry as gm

p = gm.Point(21.0, 52.0, crs=4326)
print("2D:", p.to_geojson())

pz = gm.Point(21.0, 52.0, z=100.0, crs=4979)
print("with Z:", pz.to_geojson())
2D: {"type":"Point","coordinates":[21.0,52.0]}
with Z: {"type":"Point","coordinates":[21.0,52.0,100.0]}

Construction accepts M; the restriction applies at serialization time:

import gometry as gm

pm = gm.Point(21.0, 52.0, m=5.0, crs=4326)
try:
    pm.to_geojson()
except gm.InvalidGeometryError as e:
    print("M ordinate raises on to_geojson:", type(e).__name__)
M ordinate raises on to_geojson: InvalidGeometryError

GeoJSON cannot carry M or epoch

to_geojson and __geo_interface__ raise on M; epoch metadata must be explicitly dropped with drop_epoch=True. Clear M with with_coordinates(m=None) or force_2d(), and epoch with with_epoch(None) only when that loss is intended. WKB/EWKB preserves M but not epoch. For lossless M/epoch interchange, use gometry's GeoArrow extension metadata with gometry or an extension-aware consumer; standard GeoArrow metadata alone does not carry epoch.

__geo_interface__

gometry geometries implement the __geo_interface__ protocol, so they interoperate with any library that speaks it. gm.from_geojson is the inverse — it ingests anything exposing that protocol (or a raw GeoJSON-like mapping), including a GeoJSON Feature. By default it returns the geometry and drops feature side data; pass features=True to retain properties and IDs.

import gometry as gm

p = gm.Point(21.0, 52.0, crs=4326)
mapping = p.__geo_interface__
print("mapping:", mapping)

back = gm.from_geojson(mapping, crs=4326)
feature = gm.from_geojson(
    {"type": "Feature", "properties": {"name": "site"}, "geometry": mapping},
    crs=4326,
    features=True,
)
print("round-trip:", back.to_wkt(), "| Feature:", feature.geometries[0].to_wkt())
mapping: {'coordinates': [21.0, 52.0], 'type': 'Point'}
round-trip: POINT (21 52) | Feature: POINT (21 52)

gm.from_geojson is the one entry point for everything GeoJSON-shaped: JSON strings, mappings, Features, FeatureCollections, and any object exposing __geo_interface__. GeoJSON is WGS 84 lon/lat by specification (RFC 7946 / OGC:CRS84), so decoded geometry declares OGC:CRS84 by default (matching GeoParquet); pass crs=4326 for EPSG:4326, crs=None to opt out, or crs=4979 for a WGS84-family 3D frame.

A FeatureCollection is a feature set: by default it decodes to a GeometryArray, one geometry per feature. Set features=True on the same reader to return Features with the aligned properties and IDs.

Uniform axes per coordinate sequence

RFC 7946 makes the third ordinate optional per position, but a gometry coordinate sequence is one set of columns: every vertex in a LineString, MultiPoint, or ring must share the same axes (all XY or all XYZ). Mixed positions such as [[0, 0], [1, 1, 5]] raise ParseError rather than inventing elevation (0) or using non-finite sentinels (NaN is rejected by the finite-coordinate contract). Distinct members of a GeometryCollection may still differ in axes.

import gometry as gm

g = gm.from_geojson({"type": "Point", "coordinates": [1.0, 2.0]})
print(g.to_wkt(), g.crs)
fc = {
    "type": "FeatureCollection",
    "features": [
        {"type": "Feature", "id": 7, "properties": {"name": "A"},
         "geometry": {"type": "Point", "coordinates": [1.0, 2.0]}},
        {"type": "Feature", "properties": {"name": "B"},
         "geometry": {"type": "Point", "coordinates": [3.0, 4.0]}},
    ],
}
print(gm.from_geojson(fc))
features = gm.from_geojson(fc, features=True)
geoms, props, ids = features
print(features.properties, ids, len(features.geometries))
POINT (1 2) OGC:CRS84
<GeometryArray[Point] len=2 OGC:CRS84>
({'name': 'A'}, {'name': 'B'}) (7, None) 2

features=True returns a Features container with three parallel fields. Destructure it as geoms, props, ids = gm.from_geojson(..., features=True). Use len(features.geometries) for the row count.

The outer properties and ids columns are tuples, so their alignment cannot drift after parsing; individual property dictionaries remain editable. A missing properties member normalizes to {}, while an explicit GeoJSON "properties": null remains None and round-trips as JSON null. When constructing Features directly, omitted or explicit properties=None records None for every row; pass properties={} when independently editable empty mappings are wanted.

Features.__geo_interface__

features.__geo_interface__ returns a fresh FeatureCollection with the record's geometries, properties, and IDs. Missing geometries and properties remain null, and rows without an ID omit id; encoding uses the same WGS 84, epoch, and M constraints as gm.to_feature_collection.

gm.to_feature_collection assembles a FeatureCollection from separate geometry and metadata columns, with keyword-only properties (and optional ids). Its geometry members are JSON-ready. The mapping is suitable for json.dumps only when every property value is JSON-serializable (strings, numbers, bools, None, and nested lists/dicts of those) — arbitrary Python objects in properties raise TypeError at dump time.

to_geojson honors the WGS84 contract on the way out too: reproject with to_crs(4326) first if your geometry is in another frame. For CRS-free geometry, the same contract assumes that the stored coordinates already are WGS84 longitude/latitude.

Pass one mapping to broadcast shared metadata across every row (each feature gets an independent outer dictionary), or pass an aligned iterable of mappings/None. Omitting properties creates independent empty mappings. IDs are always aligned; scalar IDs never broadcast, avoiding accidental duplicate identifiers.

Pickle, multiprocessing, and copies

Pickle applies to durable public value and container types: Geometry, GeometryArray, Circle, CircleArray, CellArray, Groups, Features, CRS, H3Cell, and S2Cell. Their CRS, epoch, and Z/M metadata remain intact through pickle.

The same value protocol supports multiprocessing, concurrent.futures, joblib, and cache storage; copy/deepcopy use it as well.

Spatial indexes and prepared geometries serialize durable input state and rebuild derived acceleration structures when loaded. Coordinates is picklable as an owner-retaining view, preserving its selected layout and flattened row indexes; iterator types are not picklable. GeometryParts is picklable as an immutable parent view; it is reconstructed from its geometry when unpickled.

copy.copy(features) returns a new record sharing its geometry, property, and ID columns, so edits to a shared property dictionary are visible through either record. copy.deepcopy(features) recursively copies property mappings and their values. On Python 3.13 and newer, copy.replace(features, ...) preserves each omitted column by identity and validates supplied columns.

With pickle protocol 5, packed GeometryArray payload columns and their offsets can be sent out of band when the pickler uses buffer_callback. Those buffers are read-only PickleBuffer values; the unpickler must receive the collected buffers. Pickle protocol 5 alone does not make ordinary process-pool transfers zero-copy or create shared memory.

Circle pickles retain the solved disk, including its exact planar values and working CRS. Loading does not repeat the optimization or replace the solution with its rounded center and radius.

Stored-format compatibility

New releases retain support for reading released pickle formats. Older releases need not read pickles written by newer ones.

The durable state includes coordinate dimensions, CRS definitions, coordinate epochs, missing rows, group offsets, and spatial-index handles, including removed handles. Packed numeric columns use little-endian values with fixed-width offsets, independent of the host architecture. Derived indexes, prepared geometry, CRS caches, and validation verdicts are rebuilt, so numerical results and diagnostics can reflect bug fixes and updated CRS authority data.

WKB, WKT, GeoJSON, GeoArrow, and GeoParquet retain their standard formats rather than acquiring a gometry-specific envelope. GeoParquet carries its own format version; unknown major versions are rejected. For storage shared with other applications, use Arrow and GeoParquet and check their documented CRS, epoch, and dimension support. Pickles require gometry and any optional packages used by the stored objects; only load pickles from trusted sources.

import pickle

import gometry as gm

point = gm.Point(1, 2, crs=4326, epoch=2020.5)
restored = pickle.loads(pickle.dumps(point))
print(restored == point, restored.crs, restored.epoch)
True EPSG:4326 2020.5

Dimensional empties

WKT and WKB preserve empty axes (POINT Z EMPTY stays XYZ). GeoJSON has no dimensional-empty or explicit empty-multipart encoding — to_geojson() emits a coordinate-less top-level empty, and raises rather than dropping a member or flattening Z/M identity. Prefer WKT/WKB/equals_identical when axes or multipart member positions matter.

Coming from Shapely? See Migrating.

See also