Seeding Deterministic Geometry with NumPy Generator
A spatial fixture generator that is not reproducible produces a suite with two failure modes instead of one: the bug you were looking for, and the bug you cannot reproduce. np.random.default_rng solves this properly — better than random.seed, better than np.random.seed — and the reason is worth understanding rather than taking on faith. This guide sits within synthetic vector data generation and covers building geometry generators on NumPy’s Generator API.
The short version: the legacy np.random.seed mutates a single global state that every library in the process shares, so any dependency that draws a random number changes your fixture. default_rng returns an object you own, and passing that object around is what makes a generated fixture depend on your seed and nothing else.
Root cause: global random state is shared state
np.random.seed(42) sets the state of one process-wide RandomState. GeoPandas, scikit-learn, hypothesis, and any test helper that draws a sample all consume from it. Add a dependency, reorder two tests, or upgrade a library that now shuffles internally, and your “deterministic” fixture changes.
There is a second reason, less often mentioned and equally important: Generator guarantees stream compatibility across NumPy versions for its core distributions in a way the legacy RandomState did not always provide, so a fixture generated today reproduces after an upgrade.
Step-by-step implementation
Step 1 — Generate coordinates as arrays, not in a loop
Building geometry one vertex at a time wastes both the vectorised generator and Shapely 2.x’s array interface. Draw the whole batch at once:
import numpy as np
import shapely
from shapely import Point, Polygon
def random_points(rng: np.random.Generator, n: int, extent) -> np.ndarray:
"""n points inside extent, as a Shapely geometry array."""
minx, miny, maxx, maxy = extent
xs = rng.uniform(minx, maxx, size=n)
ys = rng.uniform(miny, maxy, size=n)
return shapely.points(xs, ys) # vectorised construction
shapely.points builds the whole array in one call into GEOS, which is roughly an order of magnitude faster than a list comprehension over Point(...) for any batch worth generating. The same applies to shapely.polygons and shapely.linestrings.
Step 2 — Build valid polygons by construction
Rejection sampling — generate, check is_valid, retry — is the obvious approach and the wrong one, because the retry loop consumes a variable number of draws and therefore destroys reproducibility across code changes. Construct shapes that cannot be invalid instead:
def random_convex_polygons(
rng: np.random.Generator, n: int, extent, vertices: int = 6,
radius: float = 200.0,
) -> np.ndarray:
"""n convex polygons — valid by construction, no rejection sampling."""
minx, miny, maxx, maxy = extent
cx = rng.uniform(minx, maxx, size=(n, 1))
cy = rng.uniform(miny, maxy, size=(n, 1))
# Angles sorted along axis 1: vertices are visited in angular order,
# so the ring cannot cross itself.
angles = np.sort(rng.uniform(0, 2 * np.pi, size=(n, vertices)), axis=1)
radii = radius * rng.uniform(0.5, 1.0, size=(n, vertices))
xs = cx + radii * np.cos(angles)
ys = cy + radii * np.sin(angles)
# Close each ring by repeating the first vertex.
xs = np.concatenate([xs, xs[:, :1]], axis=1)
ys = np.concatenate([ys, ys[:, :1]], axis=1)
rings = shapely.linearrings(np.stack([xs, ys], axis=-1))
return shapely.polygons(rings)
The angular-sort trick makes validity a property of the construction rather than something to check afterwards, and the fixed draw count — exactly
Step 3 — Give every worker an independent stream
Under pytest-xdist, several workers generate fixtures concurrently. Seeding them identically produces identical data, which hides ordering bugs; seeding them from the clock destroys reproducibility. SeedSequence.spawn gives each worker a stream that is independent, reproducible, and derived from one root seed:
import os
import numpy as np
import pytest
ROOT_SEED = 20260811
@pytest.fixture(scope="session")
def rng() -> np.random.Generator:
"""A Generator unique to this xdist worker but derived from one root seed."""
worker = os.environ.get("PYTEST_XDIST_WORKER", "gw0")
index = int(worker.removeprefix("gw")) if worker.startswith("gw") else 0
child = np.random.SeedSequence(ROOT_SEED).spawn(64)[index]
return np.random.default_rng(child)
spawn is not the same as seeding with ROOT_SEED + index. Nearby seeds can produce correlated streams; SeedSequence runs the root through a hashing step designed so that spawned children are statistically independent. With four workers you would not notice the difference, and with sixty-four generating overlapping extents you would.
Step 4 — Put the seed in the failure message
A reproducible generator is only useful if the seed reaches whoever reads the failure. A conftest.py hook prints it once per session:
def pytest_report_header(config):
worker = os.environ.get("PYTEST_XDIST_WORKER", "gw0")
return f"spatial fixture root seed: {ROOT_SEED} (worker {worker})"
And an assertion helper attaches it to the individual failure, which matters more:
def assert_all_valid(geoms, seed: int, extent):
invalid = [g for g in geoms if not shapely.is_valid(g)]
assert not invalid, (
f"{len(invalid)} invalid geometries from seed={seed} "
f"extent={extent}; first: {invalid[0].wkt[:120]}"
)
Someone reading a CI log gets the exact command to reproduce, which is the entire point of the seeding discipline. Without it, determinism is a property nobody can use.
Verifying the generator is actually deterministic
import pytest
import numpy as np
def test_same_seed_gives_identical_geometry():
a = random_convex_polygons(np.random.default_rng(9), 50, EXTENT)
b = random_convex_polygons(np.random.default_rng(9), 50, EXTENT)
assert (shapely.to_wkb(a) == shapely.to_wkb(b)).all()
def test_different_seeds_give_different_geometry():
a = random_convex_polygons(np.random.default_rng(9), 50, EXTENT)
b = random_convex_polygons(np.random.default_rng(10), 50, EXTENT)
assert not (shapely.to_wkb(a) == shapely.to_wkb(b)).all()
def test_workers_get_independent_streams():
children = np.random.SeedSequence(ROOT_SEED).spawn(4)
batches = [random_points(np.random.default_rng(c), 100, EXTENT)
for c in children]
wkbs = [set(shapely.to_wkb(b)) for b in batches]
for i in range(len(wkbs)):
for j in range(i + 1, len(wkbs)):
assert not wkbs[i] & wkbs[j], f"workers {i} and {j} overlap"
The second test is the one people leave out, and it catches the generator that returns a constant — which passes the first test perfectly.
When a fixture should stop being generated
Generation is the right default and it has a boundary. A generated fixture describes a distribution — polygons of roughly this size, in roughly this extent, with roughly this vertex count — and some tests need a specific shape rather than a sample from a distribution.
The signal that generation has stopped helping is when a test’s assertion has to describe the fixture back to itself. If a test computes the expected answer from the same generator that produced the input, it is asserting that arithmetic is consistent rather than that the code under test is correct, and it will pass against an implementation that is entirely wrong. A regression test for a specific bug has the same problem: the geometry that triggered the bug is a particular shape, and regenerating something statistically similar does not reproduce it.
For those cases, commit the geometry as literal WKT in the test file, where a reviewer can read it. Five lines of WKT next to the assertion that depends on them is clearer than any generator call, and it cannot drift when the generator’s parameters change. Keep generation for volume and for property assertions; keep literals for regressions and for anything where the exact shape is part of the specification.
Failure modes and edge cases
Drawing a variable number of values breaks stream stability. Any conditional draw — “if the polygon is too small, draw another radius” — means a downstream change to the condition shifts every subsequent value. Draw a fixed shape and discard, rather than drawing until satisfied.
shapely.linearrings requires at least four coordinates. A ring with three distinct vertices plus its closing point is the minimum; asking for vertices=2 raises rather than producing a degenerate polygon. Validate the parameter at the top of the generator so the error names the cause.
Uniform sampling in a geographic CRS is not uniform on the ground. Points drawn uniformly in latitude cluster toward the poles by a factor of
default_rng accepts a SeedSequence, an int, or None. Passing None silently seeds from the operating system, so a missing argument produces a generator that looks correct and is not reproducible. Make the parameter required in your own helpers rather than defaulting it.
Generated fixtures should not be committed. A generator plus a seed is smaller, reviewable, and cannot drift from the code that produced it. If a fixture must be committed — because it is slow to build or shared across languages — record the seed and generator version alongside it, as covered in recording fixture provenance metadata in CI.
Conclusion
Own the random stream rather than sharing the global one, construct geometry vectorised and valid-by-construction so the draw count is fixed, spawn per-worker children from a single root seed so parallel runs are both varied and reproducible, and print the seed where a failing run will show it. The result is a fixture generator whose output is a pure function of one integer you control.
Related
- Synthetic Vector Data Generation — the parent strategy this fits into
- Writing Faker Providers for WKT and WKB — the same discipline applied to Faker’s stream
- Building Factory Boy Spatial Factories — attaching generated geometry to model instances
- Parallelizing Spatial Tests with pytest-xdist — the execution model the spawn pattern serves
- Recording Fixture Provenance Metadata in CI — storing the seed with the artefact