WKT, WKB and GeoJSON: How Geometry Is Actually Stored

Problem statement

Sooner or later a geometry stops being a shape on a map and becomes something you have to read, paste, or debug:

POLYGON ((325000 674000, 325100 674000, 325100 674100, 325000 674100, 325000 674000))
0103000000010000000500000000000000A0D31341000000000CA92441…
{"type": "Polygon", "coordinates": [[[-3.19, 55.95], [-3.18, 55.95], …]]}

Those are the same square in three encodings. A CSV column holds the first, a PostGIS geom column holds the second, an API returns the third, and GeoPandas turns all of them into the same Shapely object. Knowing which is which β€” and what each one can and cannot carry β€” explains a surprising number of day-to-day problems: why a WKB string will not paste into a spreadsheet, why a GeoJSON file is three times larger, why an SRID sometimes travels with the geometry and sometimes does not.

Quick answer

Three encodings of one model, each with a job:

  1. WKT β€” Well-Known Text. Human-readable, verbose, lossy on precision. For debugging, config files, and CSV columns.
  2. WKB β€” Well-Known Binary. Compact, exact, unreadable. What databases and most binary formats actually store.
  3. GeoJSON β€” JSON geometry plus attributes. Web-native, always WGS84 by specification, verbose.
from shapely.geometry import Polygon
from shapely import to_wkt, to_wkb, from_wkt, from_wkb
import json

square = Polygon([(0, 0), (100, 0), (100, 100), (0, 100)])

print(to_wkt(square))
print(to_wkb(square, hex=True)[:60], "…")
print(json.dumps(square.__geo_interface__)[:80], "…")

# every encoding round-trips back to the same object
assert from_wkt(to_wkt(square)).equals(square)
assert from_wkb(to_wkb(square)).equals(square)

The important property: all three describe the same Simple Features model β€” point, line, polygon, and their multi-part versions β€” so converting between them loses nothing except, in WKT's case, coordinate precision if you are not careful.

One square, three encodings

The same polygon shown as WKT text, WKB hex bytes and GeoJSON, with size and readability.
Same geometry, three representations β€” and three very different trade-offs.

Step-by-step solution

Grid comparing WKT, WKB, EWKB, GeoJSON and TopoJSON across size, precision, CRS and use.
Five encodings, and what each one carries β€” CRS is where they differ most.

WKT: readable, and the one you will paste

from shapely import from_wkt, to_wkt
from shapely.geometry import Point, LineString, Polygon, MultiPolygon

print(to_wkt(Point(1, 2)))
# POINT (1 2)

print(to_wkt(LineString([(0, 0), (1, 1), (2, 0)])))
# LINESTRING (0 0, 1 1, 2 0)

shell = [(0, 0), (1, 0), (1, 1), (0, 1)]
hole = [(0.2, 0.2), (0.4, 0.2), (0.4, 0.4), (0.2, 0.4)]
print(to_wkt(Polygon(shell, [hole])))
# POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0), (0.2 0.2, 0.4 0.2, 0.4 0.4, 0.2 0.4, 0.2 0.2))

print(to_wkt(Point(1, 2, 3)))          # 3D
# POINT Z (1 2 3)

Two things to know. Rings are closed explicitly β€” the first coordinate repeats at the end β€” and the outer ring comes first, with holes after it in the same parenthesised list.

Precision is the practical gotcha:

from shapely import to_wkt
from shapely.geometry import Point

p = Point(-3.1883247891234567, 55.9533456789012345)
print(to_wkt(p))                          # full precision by default in Shapely 2
print(to_wkt(p, rounding_precision=6))    # POINT (-3.188325 55.953346)

Rounding to six decimal places is about 0.1 m in geographic coordinates β€” usually plenty, and a large saving in file size. Rounding to two is about a kilometre, which is usually a bug.

WKB: exact, compact, and what databases store

from shapely import to_wkb, from_wkb
from shapely.geometry import Point

p = Point(-3.188325, 55.953346)
raw = to_wkb(p)
print(len(raw), "bytes")                  # 21
print(to_wkb(p, hex=True))
# 0101000000...

The anatomy of those 21 bytes:

01                                        byte order: 01 = little-endian
01000000                                  geometry type: 1 = Point
XXXXXXXXXXXXXXXX XXXXXXXXXXXXXXXX         two IEEE-754 doubles: x then y

Because coordinates are stored as raw doubles, WKB is exact β€” there is no text formatting step to round anything β€” and it is compact: a polygon with 500 vertices is about 8 KB as WKB and about 12 KB as WKT.

PostGIS extends it as EWKB, which prefixes the SRID:

from shapely import to_wkb, from_wkb

# plain WKB has no CRS at all
print(to_wkb(p, hex=True)[:10])

# EWKB carries the SRID β€” this is what a PostGIS geometry column returns
print(to_wkb(p, hex=True, include_srid=True, flavor="extended")[:20])

That difference is the source of a common confusion: geometry read from PostGIS "knows" its CRS, geometry read from a WKB column in a CSV does not.

GeoJSON: the web format, with rules attached

import json
from shapely.geometry import shape, mapping, Point

geom = {"type": "Point", "coordinates": [-3.188325, 55.953346]}
p = shape(geom)                       # dict β†’ Shapely
print(p, p.geom_type)

print(json.dumps(mapping(p)))         # Shapely β†’ dict

A GeoJSON feature wraps a geometry with properties, and a FeatureCollection wraps features:

{
  "type": "FeatureCollection",
  "features": [
    {
      "type": "Feature",
      "geometry": {"type": "Point", "coordinates": [-3.188325, 55.953346]},
      "properties": {"name": "Edinburgh Castle", "class": "monument"}
    }
  ]
}

The specification (RFC 7946) is strict in ways that matter:

  • coordinates are longitude, latitude β€” x first, as everywhere else in the stack
  • the CRS is always WGS84 (EPSG:4326); the old crs member was removed
  • polygon rings should follow the right-hand rule (exterior counter-clockwise)
  • there is no separate geometry type per layer β€” a FeatureCollection may mix types
import geopandas as gpd

gdf = gpd.read_file("data/raw/parcels.gpkg")
gdf.to_crs(4326).to_file("data/out/parcels.geojson", driver="GeoJSON")   # reproject first

Writing a projected layer straight to GeoJSON produces a file that is technically non-conforming, and every consumer that trusts the specification will place it wrongly.

Converting between all three

import geopandas as gpd
from shapely import to_wkt, to_wkb, from_wkt, from_wkb

gdf = gpd.read_file("data/raw/parcels.gpkg")

# whole-column conversions, vectorised
gdf["wkt"] = gdf.geometry.to_wkt(rounding_precision=6)
gdf["wkb_hex"] = gdf.geometry.to_wkb(hex=True)
gdf["geojson"] = gdf.geometry.map(lambda g: json.dumps(g.__geo_interface__))

# and back
from_text = gpd.GeoSeries.from_wkt(gdf["wkt"], crs=gdf.crs)
from_binary = gpd.GeoSeries.from_wkb(gdf["wkb_hex"], crs=gdf.crs)
print(from_text.geom_equals_exact(gdf.geometry, tolerance=1e-6).all())

Note crs= on both reconstructions: neither WKT nor plain WKB carries a coordinate system, so you must supply it. Forgetting is how a layer ends up with crs=None after a database round trip.

Reading a CSV with a geometry column

This is the most common real use of WKT:

import pandas as pd
import geopandas as gpd

df = pd.read_csv("data/raw/sites.csv")       # a "geometry" column of WKT strings
gdf = gpd.GeoDataFrame(
    df,
    geometry=gpd.GeoSeries.from_wkt(df["geometry"]),
    crs="EPSG:27700",                        # you have to know this β€” the file does not say
)
print(gdf.geom_type.value_counts())

And the WKB equivalent, for a database export:

gdf = gpd.GeoDataFrame(df, geometry=gpd.GeoSeries.from_wkb(df["geom"]), crs=27700)

Choosing an encoding

SIZES = {}
for name, series in {
    "wkt (full)": gdf.geometry.to_wkt(),
    "wkt (6dp)": gdf.geometry.to_wkt(rounding_precision=6),
    "wkb hex": gdf.geometry.to_wkb(hex=True),
    "wkb binary": gdf.geometry.to_wkb(),
}.items():
    SIZES[name] = sum(len(v) for v in series) / 1e6

for name, mb in sorted(SIZES.items(), key=lambda kv: kv[1]):
    print(f"{name:12} {mb:7.2f} MB")

Typical result: binary WKB is roughly half the size of hex WKB and a third the size of full-precision WKT. Hex WKB is text, so it survives CSV and JSON transport at double the byte cost.

Code examples

Example 1: a converter that keeps the CRS with the geometry

import json
import geopandas as gpd
from shapely import to_wkt, to_wkb

def export_geometry(gdf: gpd.GeoDataFrame, kind: str = "wkt", precision: int | None = 6):
    """Return a DataFrame with the geometry encoded, and the CRS recorded alongside."""
    out = gdf.drop(columns=gdf.geometry.name).copy()
    if kind == "wkt":
        out["geometry_wkt"] = gdf.geometry.to_wkt(rounding_precision=precision)
    elif kind == "wkb":
        out["geometry_wkb"] = gdf.geometry.to_wkb(hex=True)
    elif kind == "geojson":
        out["geometry_json"] = gdf.geometry.map(lambda g: json.dumps(g.__geo_interface__))
    else:
        raise ValueError(f"unknown encoding: {kind}")
    out.attrs["crs"] = gdf.crs.to_string() if gdf.crs else None
    return out

def import_geometry(df, column: str, crs: str) -> gpd.GeoDataFrame:
    if column.endswith("wkt"):
        geom = gpd.GeoSeries.from_wkt(df[column])
    elif column.endswith("wkb"):
        geom = gpd.GeoSeries.from_wkb(df[column])
    else:
        from shapely.geometry import shape
        geom = gpd.GeoSeries([shape(json.loads(v)) for v in df[column]])
    return gpd.GeoDataFrame(df.drop(columns=[column]), geometry=geom, crs=crs)

Recording the CRS explicitly alongside the encoded column is the only way a text or binary geometry stays usable.

Example 2: inspect a WKB header without a library

import struct

def wkb_header(raw: bytes) -> dict:
    """Read the first bytes of a WKB blob: endianness, type, optional SRID."""
    byte_order = "<" if raw[0] == 1 else ">"
    (type_code,) = struct.unpack(byte_order + "I", raw[1:5])
    has_srid = bool(type_code & 0x20000000)
    has_z = bool(type_code & 0x80000000)
    base = type_code & 0xFF
    names = {1: "Point", 2: "LineString", 3: "Polygon",
             4: "MultiPoint", 5: "MultiLineString", 6: "MultiPolygon",
             7: "GeometryCollection"}
    info = {"endian": "little" if raw[0] == 1 else "big",
            "type": names.get(base, base), "has_z": has_z, "has_srid": has_srid}
    if has_srid:
        (info["srid"],) = struct.unpack(byte_order + "I", raw[5:9])
    return info

print(wkb_header(bytes.fromhex("0101000000000000000000f03f0000000000000040")))
# {'endian': 'little', 'type': 'Point', 'has_z': False, 'has_srid': False}

Being able to read the first nine bytes turns "this blob is broken" into "this is an EWKB point in SRID 27700".

Example 3: precision versus file size, measured

import geopandas as gpd

gdf = gpd.read_file("data/raw/parcels.gpkg").to_crs(4326)
base = gdf.geometry.to_wkt()
full = sum(len(v) for v in base)

for dp in (7, 6, 5, 4, 3):
    text = gdf.geometry.to_wkt(rounding_precision=dp)
    size = sum(len(v) for v in text)
    ground_m = 111_320 / (10 ** dp)
    print(f"{dp} dp β†’ {size/1e6:6.2f} MB ({size/full:5.1%})  ~{ground_m:8.3f} m on the ground")

Six decimal places of longitude is about 0.1 m; four is about 11 m. Choosing deliberately is worth a large fraction of a GeoJSON's size.

Example 4: validate GeoJSON against the specification

import json
from shapely.geometry import shape

def check_geojson(path: str) -> list[str]:
    problems = []
    doc = json.loads(open(path, encoding="utf-8").read())

    if doc.get("type") != "FeatureCollection":
        problems.append(f"top level is {doc.get('type')}, expected FeatureCollection")
    if "crs" in doc:
        problems.append("has a 'crs' member β€” removed in RFC 7946; coordinates must be WGS84")

    xs, ys = [], []
    for i, feature in enumerate(doc.get("features", [])):
        geom = feature.get("geometry")
        if geom is None:
            problems.append(f"feature {i}: null geometry")
            continue
        g = shape(geom)
        if not g.is_valid:
            problems.append(f"feature {i}: invalid geometry")
        minx, miny, maxx, maxy = g.bounds
        xs += [minx, maxx]; ys += [miny, maxy]

    if xs and (max(map(abs, xs)) > 180 or max(map(abs, ys)) > 90):
        problems.append("coordinates outside WGS84 range β€” the file is probably projected")
    return problems

for problem in check_geojson("data/out/parcels.geojson"):
    print("!", problem)

Explanation

All three encodings serialise the same abstract model β€” the OGC Simple Features geometry types β€” so the differences are entirely about the medium each one targets.

Byte-level anatomy of a WKB point: endianness flag, type code, optional SRID and coordinate doubles.
Twenty-one bytes for a point β€” and the header tells you how to read the rest.

WKT optimises for a human reading it. That makes it ideal in a config file, a test fixture, an error message or a spreadsheet column, and poor as a storage format: the numbers pass through text formatting, so precision depends on the writer, and the same geometry can be written several ways. It also carries no CRS, which is why GeoSeries.from_wkt() always needs crs= supplied by you.

WKB optimises for machines. Coordinates are IEEE-754 doubles copied straight into the byte stream, so a round trip is exact and the size is predictable β€” 8 bytes per ordinate plus a small header. That is what PostGIS stores, what GeoPackage stores inside its geom blobs, and what travels over most binary protocols. The variants matter: plain WKB has no CRS, EWKB (PostGIS) prefixes an SRID, and ISO WKB encodes Z and M dimensions in the type code rather than with a flag bit.

GeoJSON optimises for the web. It is JSON, so anything can parse it; it carries attributes alongside geometry, so it is a complete feature format rather than just a geometry encoding; and RFC 7946 removed the ability to declare a CRS precisely so that consumers could stop guessing. The price is size β€” text with punctuation and repeated key names β€” and the discipline of always reprojecting to WGS84 before writing.

The practical consequence of all this is a simple rule of thumb. Store in a binary format, transport in whatever the consumer speaks, and use text only where a person or a text-only medium is involved. And whenever geometry leaves a format that knows its CRS β€” a database column, a GeoPackage β€” record the CRS explicitly next to it, because none of WKT, plain WKB or a bare coordinate array will carry it for you.

Edge cases or notes

  • WKT has no CRS: Neither does plain WKB. Always pass crs= when reconstructing a GeoSeries.
  • EWKB is a PostGIS extension: Its SRID prefix is not part of the OGC standard, though most tools read it. to_wkb(flavor="iso") produces the standard form.
  • GeoJSON is WGS84, full stop: RFC 7946 removed the crs member. Reproject before writing, or consumers will misplace your data.
  • GeoJSON winding order: The spec asks for counter-clockwise exteriors. Many writers ignore it; some renderers care. shapely.geometry.polygon.orient() normalises it.
  • Large numbers in JSON: GeoJSON numbers are doubles, so 64-bit integer ids lose precision. Write ids as strings.
  • WKT rounding is a writer setting: to_wkt(rounding_precision=-1) keeps full precision; the default in some tools is 6 and silently truncates.
  • Empty geometries encode oddly: POLYGON EMPTY is valid WKT; some parsers return None instead. Test the round trip if empties matter.

FAQ

What is the difference between WKT and WKB?

They encode the same geometry model β€” WKT as readable text, WKB as compact binary. WKB is exact and about a third the size; WKT is legible and can be pasted anywhere.

Does WKT store the coordinate system?

No. Neither does plain WKB. PostGIS's EWKB adds an SRID prefix, and formats like GeoPackage store the CRS separately in their own metadata tables.

Why does my GeoJSON have coordinates like 325000?

Because it was written from a projected layer. GeoJSON must be WGS84 under RFC 7946 β€” reproject with to_crs(4326) before writing, or consumers will place the data in the Atlantic.

How do I load a CSV whose geometry is a WKT column?

gpd.GeoSeries.from_wkt(df["geometry"]), then build a GeoDataFrame with an explicit crs=. The CSV cannot tell you the CRS, so it has to come from the documentation.

Is hex WKB the same as WKB?

It is the same bytes written as hexadecimal text, so it is twice the size but survives CSV and JSON transport. to_wkb(hex=True) produces it.

How much precision should I keep?

Match the data's real accuracy. Six decimal places of longitude is about 0.1 m and is plenty for most work; keeping fifteen just inflates the file with noise.

Which should I use for storing data?

Neither, usually β€” use a real format. GeoPackage and Parquet store WKB internally with the CRS recorded properly. Reach for raw WKT or WKB only when the geometry has to live in a text column or a message payload.