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.

Shared global state versus an owned Generator Two approaches to seeding are compared. Under np.random.seed a single process-wide random state exists, and it is consumed by the fixture generator and equally by every other library in the process, so adding a dependency, reordering two tests, or upgrading a library that now draws random numbers internally will all silently change the geometry the fixture produces. Under np.random.default_rng the seed produces a Generator object that the fixture code owns and passes explicitly to the functions that need it, so no other library can consume from that stream and the generated fixture depends on the seed and nothing else. np.random.seed — one shared state global RandomState one per process seed your fixture a dependency a test helper add a dependency → fixture changes reorder two tests → fixture changes upgrade a library that draws → fixture changes the seed does not determine the output default_rng — an object you own Generator passed explicitly seed your fixture only dependencies cannot reach this stream test order is irrelevant upgrades cannot consume from it the seed alone determines the output

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 n×(2+2v)n \times (2 + 2v) values regardless of outcome — is what keeps the stream position predictable. That predictability is what lets a later change to one generator leave the others’ output untouched.

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.

Three seeding strategies for parallel workers Three approaches to seeding parallel test workers are compared. Seeding every worker with the same value makes all of them generate identical data, which wastes the parallelism and hides ordering and isolation defects. Seeding each worker from the clock or its process identifier makes every run different, so a failure observed in continuous integration cannot be replayed locally. Spawning child sequences from a single SeedSequence gives every worker a stream that is statistically independent of the others while remaining entirely determined by one root seed, so the workers generate different data and the run as a whole still reproduces exactly. STRATEGY WORKERS DIFFER? RUN REPRODUCIBLE? VERDICT same seed everywhere default_rng(42) in every worker no — identical data yes hides isolation bugs seed from clock or pid default_rng() with no argument yes no — cannot replay failures are unusable SeedSequence.spawn one root seed, n independent children yes — independent yes use this Adding the root seed to a worker index is not equivalent — nearby seeds can produce correlated streams.

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.

Fixed draw counts keep the stream position predictable Two generators are compared by how many values each consumes from the random stream. A generator that draws a fixed number of values per feature always leaves the stream at the same position afterwards, so a later change to one generator leaves every other generator's output untouched and diffs stay small and explainable. A generator that draws repeatedly until a condition is satisfied consumes a variable number of values, so changing that condition shifts every subsequent draw in the stream, and every downstream fixture changes even though nothing about those fixtures was edited. fixed draw count — valid by construction feature 1 feature 2 feature 3 feature 4 every feature consumes the same number of values position: predictable rejection sampling — draw until valid 1 2 (3 retries) 3 4 (2 retries) retry count varies, so the stream position after feature 1 is not fixed position: unstable Change the acceptance condition and every fixture downstream of it changes, though none of them were edited.

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 1/cosφ1/\cos\varphi. For fixtures meant to be spatially representative, sample in a projected CRS or draw sinφ\sin\varphi uniformly and take the arcsine.

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.