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.

Four properties a geometry provider must control Four properties a spatial fixture generator must control are listed alongside what a plain latitude and longitude provider offers for each. Extent control keeps generated geometry inside the region that the test's other fixtures and reference layers cover; a plain provider spreads points across the whole planet. Validity control decides whether the output satisfies the Simple Features rules, because validation tests need invalid input on demand; a plain provider only ever produces points, which cannot be invalid. Type control produces the specific geometry type a schema declares; a plain provider produces points only. Reproducibility ties the geometry stream to the same seed as the attribute stream so a failure can be replayed exactly from its seed; a plain provider does offer this, and it is the reason to build on Faker rather than start from scratch. PROPERTY WHY A TEST NEEDS IT lat/lon PROVIDER extent control geometry must land where the reference layers are no — whole planet validity control validation tests need invalid input on demand no — points only type control the schema declares a type; fixtures must match it no — points only reproducibility a failing case must replay from its seed alone yes — build on this

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.

Three named invalid geometries and what each one exercises Three deliberately invalid geometries are described with what each one tests. A bow-tie ring crosses itself exactly once, so the polygon can be constructed but fails an is_valid check, which exercises a validity gate's ability to catch self-intersection. An unclosed ring has a final point that differs from its first; it has to be built as raw Well-Known Text because Shapely closes rings automatically, and it exercises the parser's error path rather than the geometry engine's. A ring containing a repeated point carries a zero-length segment, which some parsers accept and others reject, so it exercises whether the pipeline handles the ambiguous case consistently across the tools in it. bow-tie constructible, fails is_valid exercises the validity gate built via Shapely unclosed ring last point ≠ first point exercises the WKT parser built as raw text — Shapely closes rings repeated point zero-length segment accepted by some parsers, not others exercises consistency across tools

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.

Generated volume for properties, hand-built minimalism for behaviour Two kinds of test are contrasted by the fixture size each deserves. A property assertion — that every polygon is valid, that every geometry falls inside the extent, that every Well-Known Binary value parses — benefits from volume, because such a property either holds universally or a counterexample exists somewhere in the space, so roughly two hundred generated features is a sensible default and the loop costs milliseconds. A behaviour assertion — that a spatial join returns the right rows, that a validity gate rejects the right feature — needs the smallest fixture that expresses the case, typically three to five features constructed deliberately, because a failure naming a randomly generated feature cannot be reasoned about without first reconstructing it by hand. property assertion “every polygon is valid” “every geometry is inside the extent” “every WKB value parses” generate ~200 features a counterexample exists or it does not behaviour assertion “the join returns these two rows” “the gate rejects this feature” “the repair preserves this area” build 3–5 features by hand a random failing feature explains nothing Mixing them — a behavioural assertion over 200 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.