Writing Faker Providers for WKT and WKB
Faker already knows how to invent names, addresses, and coordinates, and its latitude/longitude providers are where most geospatial test data starts. They are also where it stops being useful: a random point somewhere on Earth is not a parcel boundary, is not inside the extent your fixtures assume, and cannot be made invalid on purpose. This guide sits within synthetic vector data generation and covers writing custom Faker providers that emit Well-Known Text and Well-Known Binary geometry with the properties a spatial test actually needs.
The reason to build on Faker rather than write generators from scratch is its seeding contract. One Faker.seed(n) call makes every provider in the instance reproducible, including yours, so geometry and attributes are generated from the same deterministic stream — which is what lets a failing test be reproduced from its seed alone.
Root cause: random coordinates are not test data
A test fixture needs geometry that is valid, is where the test expects it to be, and can be deliberately broken. Random latitude/longitude pairs satisfy none of those.
Step-by-step implementation
Step 1 — Write the provider against Faker’s generator
A Faker provider subclasses BaseProvider and uses self.generator.random, which is the seeded stream. Using the module-level random instead is the single most common mistake and it silently breaks reproducibility:
from faker.providers import BaseProvider
from shapely.geometry import Point, Polygon, LineString
from shapely import to_wkt, to_wkb
class GeometryProvider(BaseProvider):
"""WKT/WKB geometry bounded to a configured extent."""
# Default extent: a small area in EPSG:27700 (British National Grid).
extent = (525_000.0, 180_000.0, 535_000.0, 190_000.0)
def _xy(self) -> tuple[float, float]:
minx, miny, maxx, maxy = self.extent
rnd = self.generator.random # the seeded stream, not `random`
return (rnd.uniform(minx, maxx), rnd.uniform(miny, maxy))
def geo_point(self) -> Point:
return Point(*self._xy())
def geo_polygon(self, vertices: int = 5, radius: float = 250.0) -> Polygon:
"""A convex polygon: vertices at increasing angles guarantees no self-intersection."""
import math
cx, cy = self._xy()
rnd = self.generator.random
angles = sorted(rnd.uniform(0, 2 * math.pi) for _ in range(vertices))
ring = [
(cx + radius * rnd.uniform(0.6, 1.0) * math.cos(a),
cy + radius * rnd.uniform(0.6, 1.0) * math.sin(a))
for a in angles
]
return Polygon(ring)
def geo_linestring(self, points: int = 4, step: float = 300.0) -> LineString:
x, y = self._xy()
rnd = self.generator.random
coords = [(x, y)]
for _ in range(points - 1):
x += rnd.uniform(-step, step)
y += rnd.uniform(-step, step)
coords.append((x, y))
return LineString(coords)
def geo_wkt(self, kind: str = "polygon", **kwargs) -> str:
return to_wkt(getattr(self, f"geo_{kind}")(**kwargs), rounding_precision=6)
def geo_wkb(self, kind: str = "polygon", hex: bool = False, **kwargs):
return to_wkb(getattr(self, f"geo_{kind}")(**kwargs), hex=hex)
Sorting the angles before building the ring is what makes geo_polygon produce valid output every time. Vertices visited in angular order around a centre cannot produce a self-intersecting ring, which turns validity from something you check afterwards into something the construction guarantees.
Step 2 — Register it and confirm the seed governs geometry
import pytest
from faker import Faker
@pytest.fixture
def fake():
f = Faker()
f.add_provider(GeometryProvider)
Faker.seed(20260811)
return f
def test_geometry_is_reproducible_from_the_seed():
a, b = Faker(), Faker()
a.add_provider(GeometryProvider)
b.add_provider(GeometryProvider)
Faker.seed(7)
first = [a.geo_wkt() for _ in range(20)]
Faker.seed(7)
second = [b.geo_wkt() for _ in range(20)]
assert first == second
This test looks trivial and is the one that catches the random versus self.generator.random mistake. Without it, the provider works, the fixtures look fine, and a failure reported by CI cannot be reproduced locally — which costs far more than the test does.
Step 3 — Add deliberate invalidity as an explicit provider
Validation code needs input that fails. Generating it by accident is unreliable; generating it on request is a provider method:
class InvalidGeometryProvider(GeometryProvider):
"""Geometry that is deliberately invalid, one named defect at a time."""
def geo_bowtie_wkt(self) -> str:
cx, cy = self._xy()
d = 200.0
# Vertices ordered so the ring crosses itself exactly once.
ring = [(cx - d, cy - d), (cx + d, cy + d),
(cx + d, cy - d), (cx - d, cy + d)]
return to_wkt(Polygon(ring), rounding_precision=6)
def geo_unclosed_ring_wkt(self) -> str:
cx, cy = self._xy()
d = 150.0
pts = [(cx, cy), (cx + d, cy), (cx + d, cy + d), (cx, cy + d)]
coords = ", ".join(f"{x} {y}" for x, y in pts)
return f"POLYGON(({coords}))" # last point != first point
def geo_repeated_point_wkt(self) -> str:
cx, cy = self._xy()
d = 150.0
pts = [(cx, cy), (cx + d, cy), (cx + d, cy), # duplicate
(cx + d, cy + d), (cx, cy + d), (cx, cy)]
coords = ", ".join(f"{x} {y}" for x, y in pts)
return f"POLYGON(({coords}))"
Note that geo_unclosed_ring_wkt and geo_repeated_point_wkt build the WKT string by hand rather than going through Shapely. That is deliberate: Shapely closes rings automatically and would repair the defect before it reached the test. Producing malformed WKT requires bypassing the library that refuses to produce it.
Step 4 — Assert the generator’s own contract
A fixture generator is code, and code that generates test data deserves tests of its own — otherwise a silently broken generator produces a suite that passes because nothing meaningful was ever generated:
from shapely import from_wkt
from shapely.geometry import box
EXTENT = box(*GeometryProvider.extent)
def test_generated_polygons_are_valid_and_in_extent(fake):
for _ in range(200):
geom = from_wkt(fake.geo_wkt("polygon"))
assert geom.is_valid, geom.wkt
assert not geom.is_empty
assert EXTENT.buffer(500).contains(geom), "generated outside the extent"
def test_wkb_and_wkt_describe_the_same_geometry(fake):
from shapely import from_wkb
Faker.seed(1)
wkt_geom = from_wkt(fake.geo_wkt("polygon"))
Faker.seed(1)
wkb_geom = from_wkb(fake.geo_wkb("polygon"))
assert wkt_geom.equals_exact(wkb_geom, tolerance=1e-6)
def test_invalid_provider_actually_produces_invalid_geometry(fake_invalid):
geom = from_wkt(fake_invalid.geo_bowtie_wkt())
assert not geom.is_valid
The last of these is easy to omit and important to keep. A “bow-tie” generator that quietly starts producing valid polygons — because a coordinate ordering changed, or because a helper was refactored — turns every negative test that depends on it into a test that asserts nothing while continuing to pass.
Choosing the number of features a fixture generates
A provider makes generating a thousand features as easy as generating five, which is a trap worth naming. The size of a generated fixture should be chosen from what the test is asserting, not from what the generator can produce.
A test asserting a property — every polygon is valid, every geometry falls inside the extent, every WKB parses — benefits from volume, because the property either holds universally or a counterexample exists somewhere in the space. Two hundred features is a reasonable default there, and the loop costs milliseconds.
A test asserting a specific behaviour — a spatial join returns the right rows, a validity gate rejects the right feature — should use the smallest fixture that expresses the case, typically three to five features constructed deliberately rather than generated. Volume in that setting actively harms the test, because the failure message names a random feature that nobody can reason about and the assertion has to be reconstructed by hand before it can be understood.
The practical rule is that generated volume belongs in property tests and hand-built minimalism belongs in behaviour tests, and a provider that makes both easy is doing its job. Mixing them — a behavioural assertion over two hundred generated features — produces the failures that get marked flaky and skipped.
Failure modes and edge cases
The buffer in the extent assertion is deliberate. geo_polygon places a centre inside the extent and grows a radius around it, so vertices can fall outside. Either buffer the assertion, as above, or shrink the centre-sampling extent by the maximum radius. Silently allowing arbitrary overshoot is the option to avoid.
WKT rounding is lossy. to_wkt(rounding_precision=6) is readable and drops precision; rounding_precision=-1 preserves the full double. Use the trimmed form for fixtures a human will read and the full form whenever a test compares coordinates, or the comparison tests your rounding rather than your code.
WKB is endian-dependent in its bytes but not its meaning. Comparing WKB byte strings across platforms is unreliable; compare parsed geometries, as test_wkb_and_wkt_describe_the_same_geometry does.
Faker’s seed is global to the class. Faker.seed(n) affects every instance, so a test that seeds mid-run changes the stream for fixtures created afterwards. Seed once per test, in a fixture, rather than inline.
Extent defaults hide CRS assumptions. The extent above is in metres because the CRS is EPSG:27700. Reusing the provider with a geographic CRS produces polygons 250 degrees across. Make the CRS a provider attribute and assert it matches the layer the fixture is going into — the CRS validation gate covers the general form of that check.
Convex-only polygons are not representative. The angular-ordering trick guarantees validity by producing convex shapes, and real parcels are not convex. For tests where concavity matters, generate a convex hull and push one vertex inward, then check validity explicitly rather than assuming it.
Conclusion
Build the provider on self.generator.random so seeding works, bound it to an extent that matches the rest of your fixtures, guarantee validity by construction rather than by rejection sampling, and add a second provider whose whole purpose is producing named invalid shapes. Then test the generator itself — particularly that its invalid output really is invalid, because that assertion is what keeps every negative test in the suite meaningful.
Related
- Synthetic Vector Data Generation — the parent strategy this fits into
- Seeding Deterministic Geometry with NumPy Generator — the same problem at array scale
- Building Factory Boy Spatial Factories — wiring these providers into model factories
- Generating Synthetic GeoJSON for Edge-Case Testing — the file-level equivalent
- Preventing WKT/WKB Injection in Spatial Queries — what hand-built WKT strings must never reach