WKT, WKB and GeoJSON: How Geometry Is Actually Stored

On this page

Problem statement

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

import json

import geopandas as gpd
from shapely.geometry import Polygon

square = gpd.GeoSeries([Polygon([(325000, 674000), (325100, 674000),
                                 (325100, 674100), (325000, 674100)])], crs=27700)   # 100 m in Edinburgh
print(square.to_wkt()[0])
print(square.to_wkb(hex=True)[0][:60] + "…")
print(json.dumps(square.to_crs(4326)[0].__geo_interface__, separators=(",", ":"))[:98] + "…")
POLYGON ((325000 674000, 325100 674000, 325100 674100, 325000 674100, 325000 674000))
010300000001000000050000000000000020D6134100000000A091244100…
{"type":"Polygon","coordinates":[[[-3.20266466311559,55.953149818733095],[-3.2010636087679014,55.9…

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 larger than the GeoPackage it came from, 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)
POLYGON ((0 0, 100 0, 100 100, 0 100, 0 0))
010300000001000000050000000000000000000000000000000000000000 …
{"type": "Polygon", "coordinates": [[[0.0, 0.0], [100.0, 0.0], [100.0, 100.0], [ …

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 encoded as WKT, WKB and GeoJSON. one square, three encodingsWKTPOLYGON ((0 0, 100 0…WKB (binary)01030000000100…WKB (hex text)2× the bytes, portableGeoJSON{"type":"Polygon"…EWKB (PostGIS)WKB + an SRIDSame Simple Features model — the differences are the medium spatialworkflow.io
Same geometry, three representations — and three very different trade-offs.

Each encoding, and converting between them

Geometry encodings compared on size, precision and CRS. what each encoding carriessize, measuredcarries a CRS?WKT!1.26× WKB in metres, 2.4× in degrees✕noWKB✓16 bytes per vertex✕noEWKB✓WKB + 4 bytes✓an SRIDGeoJSON!file 1.4× the GeoPackage✓WGS84 by specNeither WKT nor plain WKB carries a CRS — you must supply it spatialworkflow.io
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)))
print(to_wkt(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])))

print(to_wkt(Point(1, 2, 3)))          # 3D
POINT (1 2)
LINESTRING (0 0, 1 1, 2 0)
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))
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.2004183749283746, 55.9486883912837465)
print(to_wkt(p))                           # Shapely 2 rounds to 6 decimals by default
print(to_wkt(p, rounding_precision=-1))    # -1 asks for full precision
print(to_wkt(p, rounding_precision=2))
POINT (-3.200418 55.948688)
POINT (-3.2004183749283746 55.948688391283746)
POINT (-3.2 55.95)

The default surprises people: Shapely 2's to_wkt rounds to six decimal places unless told otherwise, so a WKT round trip is lossy out of the box. In a projected CRS six decimals is a micrometre and harmless; in degrees it is the 0.1 m below.

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.200418, 55.948688)           # Edinburgh Castle, from OpenStreetMap
raw = to_wkb(p)
print(len(raw), "bytes")
print(to_wkb(p, hex=True))
21 bytes
0101000000E23C9CC0749A09C0C70DBF9B6EF94B40

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: 16 bytes per vertex plus a few bytes of header, however many digits the numbers have. The measured sizes for a whole parcel layer are under "Choosing an encoding" below.

PostGIS extends it as EWKB, which prefixes the SRID:

import shapely
from shapely import to_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
with_srid = shapely.set_srid(p, 4326)
print(to_wkb(with_srid, hex=True, include_srid=True, flavor="extended")[:18])
0101000000
0101000020E6100000

The type code changes from 01000000 to 01000020 — the 0x20000000 flag that says an SRID follows — and the next four bytes, E6100000, are 4326 in little-endian.

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.200418, 55.948688]}
p = shape(geom)                       # dict → Shapely
print(p, p.geom_type)

print(json.dumps(mapping(p)))         # Shapely → dict
POINT (-3.200418 55.948688) Point
{"type": "Point", "coordinates": [-3.200418, 55.948688]}

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

{
  "type": "FeatureCollection",
  "features": [
    {
      "type": "Feature",
      "geometry": {"type": "Point", "coordinates": [-3.200418, 55.948688]},
      "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 os
import geopandas as gpd

gdf = gpd.read_file("data/raw/parcels.gpkg")
os.makedirs("data/out", exist_ok=True)
gdf.to_crs(4326).to_file("data/out/parcels.geojson", driver="GeoJSON",
                         RFC7946="YES")          # reproject first, and write to the RFC
for path in ("data/raw/parcels.gpkg", "data/out/parcels.geojson"):
    print(f"{path:26} {os.path.getsize(path) / 1e6:6.1f} MB")
data/raw/parcels.gpkg        59.7 MB
data/out/parcels.geojson     83.2 MB

RFC7946="YES" matters even after reprojecting. Without it GDAL writes a crs member naming CRS84 into every file — the member RFC 7946 removed — and keeps up to 15 decimals; with it, the member goes and coordinates are rounded to 7 decimals, about a centimetre. Writing a projected layer straight to GeoJSON is worse: a file in metres that every consumer trusting the specification will place wrongly (Example 4).

Size of the geometry column of 121,064 Manchester parcels in four encodings. 121,064 parcels, one geometry column, four encodingsWKB binary40.93 MBWKT, metres (BNG)51.44 MBWKB as hex text81.86 MB · exactly 2×WKT, degrees, full96.39 MB0100 MBWKB is fixed at 16 bytes a vertex; WKT grows with every digit it has to write spatialworkflow.io
Measured under Choosing an encoding and in Example 3.

Converting between all three

import json
import geopandas as gpd

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("WKT round trip within 1e-6:", from_text.geom_equals_exact(gdf.geometry, tolerance=1e-6).all())
print("WKB round trip exact:      ", from_binary.geom_equals_exact(gdf.geometry, tolerance=0).all())
WKT round trip within 1e-6: True
WKB round trip exact:       True

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())
Polygon    1000
Name: count, dtype: int64

And the WKB equivalent, for a database export — here the same file's hex geom column:

gdf = gpd.GeoDataFrame(df, geometry=gpd.GeoSeries.from_wkb(df["geom"]), crs=27700)
print(len(gdf), gdf.crs.to_epsg(), gdf.geometry.iloc[0].geom_type)
1000 27700 Polygon

Choosing an encoding

gdf = gpd.read_file("data/raw/parcels.gpkg")
SIZES = {}
for name, series in {
    "wkt (full)": gdf.geometry.to_wkt(rounding_precision=-1),
    "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")
wkb binary     40.93 MB
wkt (6dp)      51.44 MB
wkt (full)     51.44 MB
wkb hex        81.86 MB

Binary WKB is exactly half of hex WKB — two hex characters per byte — which is the cost of making it survive CSV and JSON transport. How it compares with WKT depends on the digits: these parcels are in British National Grid metres with three decimals, so their WKT is short: binary WKB is only 1.26 times smaller (40.93 against 51.44 MB), and full precision adds nothing because there are no more digits to keep. The same parcels in degrees are a different story — full-precision WKT is 96.39 MB there (Example 3), 2.4 times the WKB.

Encodings on Manchester's land parcels

The data used on this page

The parcels are HM Land Registry's INSPIRE Index Polygons for Manchester — every freehold parcel boundary in the city, published under the Open Government Licence, in British National Grid (EPSG:27700). The setup saves them as data/raw/parcels.gpkg, and writes the first 1,000 as data/raw/sites.csv the way a database export arrives: an id, the geometry as WKT in a geometry column, and the same geometry as hex WKB in a geom column — and nothing that says which CRS the numbers are in.

import os

import geopandas as gpd
import pandas as pd
import requests

INSPIRE = "https://use-land-property-data.service.gov.uk/datasets/inspire/download/Manchester_City_Council.zip"
os.makedirs("data/raw", exist_ok=True)
if not os.path.exists("Manchester_City_Council.zip"):
    with requests.Session() as session:                    # the service sets a cookie, then redirects
        open("Manchester_City_Council.zip", "wb").write(session.get(INSPIRE, timeout=600).content)
if not os.path.exists("data/raw/parcels.gpkg"):
    parcels = gpd.read_file("/vsizip/Manchester_City_Council.zip/Land_Registry_Cadastral_Parcels.gml")
    parcels = parcels[["INSPIREID", "geometry"]].rename(columns={"INSPIREID": "inspire_id"})
    parcels.set_crs(27700, allow_override=True).to_file("data/raw/parcels.gpkg")
if not os.path.exists("data/raw/sites.csv"):
    first = gpd.read_file("data/raw/parcels.gpkg", rows=1000)
    pd.DataFrame({"inspire_id": first.inspire_id,
                  "geometry": first.geometry.to_wkt(rounding_precision=-1),
                  "geom": first.geometry.to_wkb(hex=True)}).to_csv("data/raw/sites.csv", index=False)
print(f"{len(gpd.read_file('data/raw/parcels.gpkg')):,} Manchester parcels; sites.csv "
      f"{os.path.getsize('data/raw/sites.csv') / 1e6:.1f} MB")
121,064 Manchester parcels; sites.csv 0.9 MB

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)
parcels = gpd.read_file("data/raw/parcels.gpkg", rows=500)
for kind, column in (("wkt", "geometry_wkt"), ("wkb", "geometry_wkb"), ("geojson", "geometry_json")):
    table = export_geometry(parcels, kind)
    back = import_geometry(table, column, table.attrs["crs"])
    same = back.geometry.geom_equals_exact(parcels.geometry, tolerance=1e-6).all()
    print(f"{kind:8} crs recorded {table.attrs['crs']}, round trip equal: {same}")
wkt      crs recorded EPSG:27700, round trip equal: True
wkb      crs recorded EPSG:27700, round trip equal: True
geojson  crs recorded EPSG:27700, round trip equal: True

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")))   # POINT (1 2)

first = gpd.read_file("data/raw/parcels.gpkg", rows=1).geometry.iloc[0]
ewkb = shapely.to_wkb(shapely.set_srid(first, 27700), include_srid=True, flavor="extended")
print(wkb_header(ewkb))
{'endian': 'little', 'type': 'Point', 'has_z': False, 'has_srid': False}
{'endian': 'little', 'type': 'Polygon', 'has_z': False, 'has_srid': True, 'srid': 27700}

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(rounding_precision=-1)      # the default would already round to 6
full = sum(len(v) for v in base)
print(f"full precision: {full / 1e6:.2f} MB")

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")
full precision: 96.39 MB
7 dp →  57.21 MB (59.4%)  ~   0.011 m on the ground
6 dp →  52.29 MB (54.3%)  ~   0.111 m on the ground
5 dp →  47.38 MB (49.2%)  ~   1.113 m on the ground
4 dp →  42.46 MB (44.1%)  ~  11.132 m on the ground
3 dp →  37.55 MB (39.0%)  ~ 111.320 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

print("reprojected:", check_geojson("data/out/parcels.geojson") or "no problems")

gpd.read_file("data/raw/parcels.gpkg", rows=200).to_file("data/out/projected.geojson", driver="GeoJSON")
for problem in check_geojson("data/out/projected.geojson"):
    print("!", problem)
reprojected: ['feature 12581: invalid geometry', 'feature 71568: invalid geometry', 'feature 91611: invalid geometry']
! has a 'crs' member — removed in RFC 7946; coordinates must be WGS84
! coordinates outside WGS84 range — the file is probably projected

The reprojected file conforms, but three of its 121,064 parcels are no longer valid: each was valid in the source and after reprojection, and self-intersects once its coordinates are rounded to the 7 decimals RFC 7946 output uses. Rounding moves vertices, and a thin sliver can fold over itself. Check validity after any precision reduction, not only before it.

The projected file is what to_file writes when nobody reprojects: GDAL records the British National Grid in a crs member and writes coordinates in metres, and a consumer that follows RFC 7946 reads an easting of hundreds of thousands of metres as a longitude.

One geometry model, three media

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.

The byte-level anatomy of a WKB point. twenty-one bytes for a point01byte order01000000type = Point8 bytesx as a double8 bytesy as a doubleEWKB inserts an SRID after the type; ISO WKB encodes Z and M therecoordinates are raw doubles, so a round trip is exact spatialworkflow.io
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.

What each format leaves out

  • 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; Shapely 2's default is 6 decimals and silently rounds.
  • 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 always 16 bytes per vertex; how much smaller it is depends on the digits WKT has to write — 1.26 times for Manchester parcels in metres, 2.4 times for the same parcels in full-precision degrees.

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 read metres as degrees — no longitude runs into the hundreds of thousands — and reject the file or misplace it.

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.