Skip to content

Repository files navigation

postgis-fixtures

A pytest plugin that gives spatial test suites an ephemeral PostGIS database and deterministic, realistic spatial fixture data.

What it does

  • Provides a database. A session-scoped fixture starts an ephemeral postgis/postgis container through testcontainers, or — when a service container already exists, as in CI — uses a DSN you configure. The choice is made by one pure function, and the readiness wait is a bounded retry loop that fails with a diagnostic instead of hanging.
  • Generates spatial data worth testing against. Seeded generators produce clustered points, route-like linestrings and valid polygons in WGS84, and reproject them to Web Mercator or a projected national grid. The same seed always yields byte-identical WKT.
  • Ships named datasets. cities, delivery_routes, service_areas, sensor_readings (timestamp-correlated, for BRIN-shaped tests) and edge_cases — antimeridian, hole, degenerate, duplicate, empty, NULL and wrong-SRID geometry, so you can test your error handling on purpose rather than in production.
  • Emits the schema. CREATE TABLE with correctly typed geometry columns, CREATE INDEX ... USING GIST (including partial and composite indexes) and a COPY-based bulk loader.
  • Asserts spatially. assert_geometry_valid, assert_srid, assert_within_distance, assert_geometries_equal and assert_uses_index, each with a failure message that says by how much, in what units and in which CRS.

Why it exists

Spatial test suites tend to fail in one of two ways.

The first is that they have no data. Someone inserts three hand-written points near the origin, the table has four rows, and every query in the suite gets a sequential scan. The tests pass. In production the same query gets a GiST index scan with wildly different behaviour, and the one thing the test suite could have caught — a predicate the index cannot support — it never saw. Realistic distribution matters here: a uniform random scatter is not production data either, because real spatial data is clustered, and clustering is what drives index selectivity.

The second is that the data is random, so the tests are too. A generator without a fixed seed cannot be asserted against; you end up writing assert len(rows) > 0 and calling it a test. Worse, when it does fail, it fails on data nobody can reproduce.

postgis-fixtures fixes both. Data generation is seeded and pure, so the same seed produces byte-identical WKT and you can hard-code expected values. The distribution is deliberately clustered around urban centres with a uniform background scatter, so index behaviour in tests resembles index behaviour in production. And the whole generation, DDL and assertion layer runs offline — this repository's own 287 tests need no Docker, no network and no PostgreSQL.

Install

The tool is not published to PyPI. Clone it and put it on your PYTHONPATH:

git clone https://github.com/postgis-python/postgis-fixtures.git
cd postgis-fixtures
python -m venv .venv
.venv/bin/pip install -r requirements.txt

To use it from another project, either add the clone to PYTHONPATH or vendor the postgis_fixtures/ package into your own tree, then enable the plugin from your project's root conftest.py:

pytest_plugins = ["postgis_fixtures.plugin"]

Or, without touching conftest.py, per invocation:

PYTHONPATH=/path/to/postgis-fixtures pytest -p postgis_fixtures.plugin

Two dependencies are optional and the plugin degrades cleanly without them: SQLAlchemy (enables the postgis_engine fixture; without it that fixture skips) and testcontainers[postgres] (enables ephemeral containers; without it you must configure a DSN).

Usage

Inspect what will be generated, before wiring anything up

$ python -m postgis_fixtures list
cities                200 rows  Point settlements clustered around urban centres, with a uniform background scatter.
delivery_routes        40 rows  Route-like linestrings generated by a bounded random walk with slowly drifting heading.
service_areas          25 rows  Valid, non-self-intersecting coverage polygons in two shape families.
sensor_readings       500 rows  Append-only readings at fixed sensor locations, timestamp-correlated for BRIN tests.
edge_cases              7 rows  Antimeridian, hole, degenerate, duplicate, empty, NULL and wrong-SRID geometry.

The DDL is generated by pure functions, so it can be diffed in review or piped straight into psql:

$ python -m postgis_fixtures ddl --dataset cities
CREATE TABLE IF NOT EXISTS cities (
    id integer PRIMARY KEY,
    name text NOT NULL,
    population integer,
    cluster text,
    geom geometry(POINT, 4326) NOT NULL
);
COMMENT ON TABLE cities IS 'Clustered settlement points for spatial-join and KNN tests.';
CREATE INDEX IF NOT EXISTS cities_geom_gist ON cities USING gist (geom);
CREATE INDEX IF NOT EXISTS cities_population_btree ON cities USING btree (population);
ANALYZE cities;

Look at the geometry itself

Routes have plausible vertex spacing — roughly 450 m apart with a slowly drifting heading — rather than random zig-zags:

$ python -m postgis_fixtures sample delivery_routes --rows 2
id=1  code=RT-0001  vehicle=van-02  stops=9  srid=4326
    LINESTRING (-2.1990242 53.5259351, -2.1927562 53.5279124, -2.1882543 53.5315193, -2.1849669 53.5350127, -2.1800585 53.5381275, -2.1746327 53.5406216, -2.1686636 53.5425083, -2.1645723 53.5458702, -2.1581283 53.5482798, -2.1530154 53.5504809, -2.150089 53.5546886, -2.1510708 53.5583525, -2.1537944 53.5626585, -2.1543275 53.5666354, -2.1570633 53.5705636, -2.157988 53.575059, -2.1581528 53.5788512, -2.1598857 53.5833218, -2.1592765 53.5877939, -2.1591672 53.5919914, -2.15573 53.5958719, -2.1504119 53.5990558, -2.1439512 53.6002246, -2.1377164 53.602785)
id=2  code=RT-0002  vehicle=van-03  stops=8  srid=4326
    LINESTRING (-0.0706202 51.6415095, -0.0761767 51.6399258, -0.081702 51.638166, -0.0869906 51.6371023, -0.0923198 51.6350216, -0.0976635 51.6336969, -0.1026947 51.6352591, -0.1085783 51.6364137, -0.1148406 51.6357061, -0.120252 51.6345917, -0.1269868 51.6339376, -0.1309247 51.6305888, -0.1367984 51.6283148, -0.1413655 51.6259194, -0.1465092 51.6239823, -0.1521231 51.6234968, -0.1579167 51.6263179, -0.1607391 51.6301285, -0.158488 51.6340013, -0.1573011 51.6376373, -0.1595591 51.6408421, -0.1614198 51.6445969, -0.1633721 51.6480661, -0.1639867 51.6517704)

The same seed with a different SRID reprojects the whole dataset, which is how you catch CRS-handling bugs:

$ python -m postgis_fixtures --seed 20260719 --srid 27700 sample cities --rows 2
id=1  name=Babbury  population=350541  cluster=Edinburgh  srid=27700
    POINT (319882.432 668741.258)
id=2  name=Cabton  population=1215795  cluster=Edinburgh  srid=27700
    POINT (333691.473 672168.801)

The edge-case catalogue

$ python -m postgis_fixtures edge-cases
antimeridian_linestring (SRID 4326)
    Crosses the 180th meridian; naive ST_Envelope spans the whole globe.
    LINESTRING (179.4 -16.5, -179.6 -16.62)
polygon_with_hole (SRID 4326)
    Interior ring: ST_Area and point-in-polygon must respect the hole.
    POLYGON ((-1 51, -0.6 51, -0.6 51.3, -1 51.3, -1 51), (-0.9 51.05, -0.7 51.05, -0.7 51.2, -0.9 51.2, -0.9 51.05))
zero_length_linestring (SRID 4326)
    Both vertices identical; ST_Length is 0 and ST_Azimuth errors.
    LINESTRING (-2.5 53.4, -2.5 53.4)
duplicate_points (SRID 4326)
    MultiPoint with repeated coordinates; distinct-count logic often breaks.
    MULTIPOINT ((-0.1276 51.5072), (-0.1276 51.5072), (-0.128 51.5075))
empty_geometry (SRID 4326)
    Non-NULL but empty; ST_IsEmpty is true and ST_Centroid returns empty.
    POLYGON EMPTY
null_geometry (SRID -)
    SQL NULL geometry; every ST_* function returns NULL, silently.
    NULL
wrong_srid (SRID 3857)
    Degree coordinates tagged as EPSG:3857; distances come out ~0 metres.
    POINT (-0.1276 51.5072)

The edge_cases table's geometry column is deliberately unconstrained (geometry, no type or SRID modifier) and nullable, because a constrained column would reject the wrong-SRID row — the row most worth testing against.

Writing a spatial test

from postgis_fixtures import assert_uses_index, assert_within_distance

def test_nearest_depots(postgis_db, spatial_fixtures):
    cities = spatial_fixtures["cities"]
    postgis_db.create_table(cities)
    postgis_db.load(cities)

    rows = postgis_db.fetchall(
        "SELECT name, ST_AsText(geom) FROM cities "
        "ORDER BY geom <-> ST_SetSRID(ST_MakePoint(-0.1276, 51.5072), 4326) LIMIT 5"
    )
    for name, wkt in rows:
        assert_within_distance(wkt, "POINT (-0.1276 51.5072)", 300_000, label_a=name)

    plan = postgis_db.explain(
        "SELECT id FROM cities WHERE geom && ST_MakeEnvelope(-1, 51, 0.5, 52, 4326)",
        analyze=True,
    )
    assert_uses_index(plan, "cities_geom_gist")

postgis_connection opens a transaction and rolls it back after every test, so tables created inside a test disappear on their own — no truncation step, no ordering coupling between tests.

A fuller, runnable version lives in examples/: a conftest.py showing the wiring and test_geofence_queries.py covering containment joins, KNN ordering, ST_DWithin on geography, index usage, reprojection round trips and NULL/empty handling.

Failure messages

The assertion helpers exist because a bare assert False tells you nothing when the bug is a CRS mix-up. Real output:

the planner did not use index 'cities_geom_gist'; it used a sequential scan.
  plan:
    Seq Scan on cities  (cost=0.00..24.50 rows=2 width=40)
      Filter: (geom && '0101...'::geometry)
  hint: a fixture table is often too small for the planner to bother, and un-ANALYZEd statistics make that worse. Load enough rows, ANALYZE, and check that the operator in the predicate is one the index supports.
  see: https://www.postgis-python.com/advanced-gist-indexing-optimization/
route has SRID EPSG:4326, expected EPSG:3857
  expected units: metre
  actual units:   degree
  these CRSs differ in kind (one geographic, one projected), so distance and area comparisons between them are meaningless, not merely imprecise.
  see: https://www.postgis-python.com/spatial-schema-migrations-and-evolution/
depot is 70,197.140 metre from drop, which exceeds the limit of 1,000.000 metre by 69,197.140 metre
  hint: if the limit looks off by a factor of ~100,000 the query is probably measuring degrees; ST_DWithin on geometry uses the column's own units.
  see: https://www.postgis-python.com/mastering-core-spatial-query-patterns/

Running the example suite

With no database configured, the examples skip rather than error:

$ PYTHONPATH=. pytest examples -q -rs
sssssssssssss                                                            [100%]
=========================== short test summary info ============================
SKIPPED [13] examples/test_geofence_queries.py: no PostGIS available: No PostGIS database available. Either set POSTGIS_FIXTURES_DSN (or the postgis_dsn ini option) to a running PostGIS instance, or install testcontainers so an ephemeral container can be started.
13 skipped in 0.00s

Point it at a real database and they run:

POSTGIS_FIXTURES_DSN=postgresql://gis:gis@localhost:5432/gis PYTHONPATH=. pytest examples

Or let testcontainers start one, naming the image you want:

PYTHONPATH=. pytest examples --postgis-image=postgis/postgis:16-3.4

On Docker Desktop, disable the Ryuk reaper

testcontainers starts a companion container, Ryuk, to garbage-collect anything left behind. Ryuk bind-mounts the Docker socket, and Docker Desktop — on macOS, Windows and any Linux install using the Desktop VM rather than plain Docker Engine — does not share that socket path with containers by default. The container fails to start with mounts denied: the path /socket_mnt/.../docker.sock is not shared from the host, and every test that needs a database skips.

Turn the reaper off:

TESTCONTAINERS_RYUK_DISABLED=true PYTHONPATH=. pytest examples --postgis-image=postgis/postgis:16-3.4

The trade-off is that containers are then reaped by testcontainers' own session teardown rather than by a watchdog, so a hard kill (SIGKILL, a crashed CI runner) can leave one running; docker ps and docker rm -f clean up. Plain Docker Engine on Linux needs none of this.

How it works

Provider selection

select_provider is a pure function of four values — the environment DSN, the ini DSN, the requested image and whether testcontainers is importable — and returns a ProviderChoice. Precedence is environment DSN, then ini DSN, then a container; an explicit choice always beats an implicit one. Blank and whitespace-only DSNs count as unset, which is what you want when CI exports POSTGIS_FIXTURES_DSN="" for a job that does not need it. If nothing is available the plugin raises with both remedies named, and dependent tests skip.

Readiness is a bounded loop (30 attempts, one second apart by default) around a probe that connects and calls postgis_lib_version(). sleep and the clock are injectable, so the retry logic is unit-tested in microseconds. On exhaustion the error reports the attempt count, the elapsed time and the last driver exception, with the password redacted from the DSN.

Determinism

Every generator takes a random.Random derived from f"{seed}:{salt}". The salt is per-dataset, which means adding a dataset to a suite does not shift the values produced for the datasets already there — a property the test suite asserts directly.

Byte-identical WKT needs one more thing: fixed precision. Coordinates are rounded to 7 decimal places in geographic CRSs (about a centimetre) and 3 in projected ones (millimetres) before serialisation, so the output does not drift with platform float formatting or shapely versions.

Clustering

A configurable fraction of points (75% by default) is drawn from a weighted urban centre with a Gaussian offset sized in metres and converted to degrees at that centre's latitude; the rest is a uniform scatter over the bounding box. Points are clamped into the box, so heavy-tailed draws pile up marginally at the edges rather than escaping it.

Routes and polygons

Routes are a bounded random walk: a heading that drifts by a normal turn each step, with the step length jittered ±15% around the nominal spacing. Polygons are convex hulls of a jittered point cloud (valid and non-self-intersecting by construction) or buffered route corridors, mixed roughly two to one so the table holds both compact and elongated shapes.

Limitations, honestly

  • Buffering a geographic geometry approximates metres as degrees at the geometry's centroid latitude. That is the same trade-off you make buffering in the database without casting to geography, and it is wrong in the same way — fine for fixture generation, not a substitute for ST_Buffer on geography.
  • Generated "cities" are plausible-looking, not real. Names are synthetic and populations come from a triangular distribution; the data is shaped for index and query behaviour, not for geocoding tests.
  • The default extent is Great Britain plus a margin of sea, which is what makes EPSG:27700 a meaningful reprojection target. Points may land in water. If your tests care, supply your own BoundingBox and UrbanCentre set.
  • assert_uses_index parses EXPLAIN text. It recognises Index Scan, Index Only Scan, Bitmap Index Scan and Bitmap Heap Scan; an exotic plan shape may need db.explain(...) and your own assertion.
  • The where argument to db.count() is interpolated verbatim. It is for test-authored SQL, not user input.

Configuration

Command-line flags:

Flag Effect
--postgis-image IMAGE Container image to start when no DSN is configured. Default postgis/postgis:16-3.4.
--postgis-keep Skip teardown and print the (redacted) DSN, so you can attach psql to the database a failing test left behind.
--postgis-seed N Seed for spatial data generation.

Ini options, in pytest.ini / setup.cfg / pyproject.toml:

Option Effect
postgis_dsn DSN of an existing PostGIS instance.
postgis_image Container image; the CLI flag wins.
postgis_seed Generation seed; the CLI flag wins.
postgis_row_counts Line list of name = count pairs, one per dataset.

Environment: POSTGIS_FIXTURES_DSN takes precedence over every ini option.

Fixtures:

Fixture Scope What it gives you
postgis_dsn session DSN of a database that has passed a readiness probe.
postgis_engine session SQLAlchemy Engine, bound to the psycopg 3 driver. Skips without SQLAlchemy.
postgis_connection function psycopg connection in a transaction, rolled back after the test.
postgis_db function The PostgisDB facade: execute, fetchall, scalar, create_table, load, count, explain.
spatial_fixtures session The generated datasets. Needs no database.
postgis_seed, spatial_config session The resolved seed and GeneratorConfig.

A typical CI configuration, where the service container already exists:

env:
  POSTGIS_FIXTURES_DSN: postgresql://gis:gis@localhost:5432/gis

Testing

$ .venv/bin/pip install -r requirements-dev.txt
$ .venv/bin/python -m pytest
287 passed in 1.50s

The suite needs no Docker, no network and no PostgreSQL. The generation, DDL, provider-selection and assertion-message layers are tested directly; the container-dependent fixtures are tested through pytest's own pytester fixture, which runs throwaway pytest sessions against a fake provider and a fake psycopg connection, so the real fixture graph — scopes, ordering, teardown — is exercised without a database.

Coverage of postgis_fixtures/, excluding the __main__ shim, is 100%:

$ .venv/bin/python -m pytest -q --cov=postgis_fixtures --cov-report=term-missing
Name                             Stmts   Miss  Cover   Missing
--------------------------------------------------------------
postgis_fixtures/__init__.py        11      0   100%
postgis_fixtures/__main__.py         5      5     0%   3-10
postgis_fixtures/assertions.py     127      0   100%
postgis_fixtures/cli.py             83      0   100%
postgis_fixtures/crs.py             35      0   100%
postgis_fixtures/datasets.py       174      0   100%
postgis_fixtures/db.py             102      0   100%
postgis_fixtures/ddl.py            120      0   100%
postgis_fixtures/errors.py          13      0   100%
postgis_fixtures/geometry.py       163      0   100%
postgis_fixtures/plugin.py         114      0   100%
postgis_fixtures/provider.py        92      0   100%
--------------------------------------------------------------
TOTAL                             1042      5    99%
287 passed in 1.60s

Further reading

License

MIT. See LICENSE.

About

Pytest plugin providing an ephemeral PostGIS database and deterministic, realistic spatial fixture data. Seeded generators for clustered points, routes and polygons across multiple CRSs, ready-made datasets with GiST/BRIN DDL, a COPY bulk loader, an edge-case catalogue, and spatial assertion helpers including EXPLAIN index checks.

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages