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:
- WKT β Well-Known Text. Human-readable, verbose, lossy on precision. For debugging, config files, and CSV columns.
- WKB β Well-Known Binary. Compact, exact, unreadable. What databases and most binary formats actually store.
- 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
Step-by-step solution
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
crsmember 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.
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 aGeoSeries. - 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
crsmember. 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 EMPTYis valid WKT; some parsers returnNoneinstead. Test the round trip if empties matter.
Internal links
- Shapely Basics: Working with Geometry Objects in Python
- How to Read a CSV with Coordinates as a GeoDataFrame
- How to Export GeoJSON in Python with GeoPandas
- How to Connect GeoPandas to PostGIS
- GIS Vector File Formats Compared: Shapefile, GeoPackage, GeoJSON, Parquet
- Coordinate Precision and Floating Point in GIS Explained
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.