A pytest plugin that gives spatial test suites an ephemeral PostGIS database and deterministic, realistic spatial fixture data.
- Provides a database. A session-scoped fixture starts an ephemeral
postgis/postgiscontainer 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) andedge_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 TABLEwith correctly typed geometry columns,CREATE INDEX ... USING GIST(including partial and composite indexes) and aCOPY-based bulk loader. - Asserts spatially.
assert_geometry_valid,assert_srid,assert_within_distance,assert_geometries_equalandassert_uses_index, each with a failure message that says by how much, in what units and in which CRS.
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.
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.txtTo 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.pluginTwo 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).
$ 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;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)$ 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.
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.
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/
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.00sPoint it at a real database and they run:
POSTGIS_FIXTURES_DSN=postgresql://gis:gis@localhost:5432/gis PYTHONPATH=. pytest examplesOr let testcontainers start one, naming the image you want:
PYTHONPATH=. pytest examples --postgis-image=postgis/postgis:16-3.4testcontainers 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.4The 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.
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.
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.
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 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.
- 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 forST_Bufferongeography. - 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
BoundingBoxandUrbanCentreset. assert_uses_indexparsesEXPLAINtext. It recognisesIndex Scan,Index Only Scan,Bitmap Index ScanandBitmap Heap Scan; an exotic plan shape may needdb.explain(...)and your own assertion.- The
whereargument todb.count()is interpolated verbatim. It is for test-authored SQL, not user input.
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$ .venv/bin/pip install -r requirements-dev.txt
$ .venv/bin/python -m pytest
287 passed in 1.50sThe 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- Mastering core spatial query patterns — bounding-box
&&,ST_DWithin, KNN<->and spatial joins, which are the queries the example suite exercises. - Advanced GiST indexing and optimization — partial and composite indexes, index-only scans,
EXPLAIN (ANALYZE, BUFFERS), and when BRIN beats GiST. Directly relevant toassert_uses_index. - Spatial schema migrations and evolution — in-place SRID reprojection and concurrent index builds, the operations the SRID assertions are meant to protect.
- SQLAlchemy and GeoAlchemy integration workflows — model mapping and type coercion, if you use the
postgis_enginefixture. - Spatial performance monitoring and observability —
pg_stat_statementsand GiST bloat detection, for when a test-suite finding needs confirming in production.
MIT. See LICENSE.