← Back to CI/CD Pipelines for Spatial APIs
Stand up a real postgis/postgis:16-3.4 inside a GitHub Actions job, wait for it correctly, enable the extension, seed real geometries, and run async pytest against it — so your spatial queries are tested by the same engine that runs in production.
Context & when to use
A GitHub Actions service container is the lightest way to give a job a real database. You declare an image under services:, the runner starts it before your steps, and your steps reach it over the network. For a FastAPI + PostGIS service this is almost always the right first choice: it is a few lines of YAML, it starts once per job, and it maps cleanly onto the health-check plumbing GitHub already provides. This is the path the CI/CD pipeline overview recommends for the integration stage.
Prefer a service container when every test can share one database version and one schema, and when your steps run directly on the runner (the default). Reach for Testcontainers instead when tests must spin databases up and down in code or need a fresh database per case; reach for docker-compose when you are testing the whole system, not the query. For a single spatial test suite that asserts on ST_Intersects, ST_DWithin, and GiST index selection, the service container wins on simplicity and speed.
The one thing a service container will not do for you is create the PostGIS extension. The image ships the binaries, but CREATE EXTENSION postgis still has to run against your test database before any ST_ function resolves — and that step, plus waiting for the server to actually accept connections, is where most first attempts fail.
Data flow
Runnable implementation
The complete workflow. It declares the service with a health check, waits for readiness, creates the extension, and runs pytest against localhost:5432.
# .github/workflows/test.yml
name: test
on: [push, pull_request]
jobs:
integration:
runs-on: ubuntu-latest
services:
postgis:
# Pin the SAME tag you deploy — CI must not drift from production.
image: postgis/postgis:16-3.4
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: gis_test
ports:
# host:container — reach it at localhost:5432 from runner steps.
- 5432:5432
# The runner waits until this command succeeds before starting steps.
options: >-
--health-cmd "pg_isready -U postgres -d gis_test"
--health-interval 5s
--health-timeout 5s
--health-retries 10
--health-start-period 10s
env:
# asyncpg driver URL used by the app + tests. Host is localhost.
DATABASE_URL: postgresql+asyncpg://postgres:postgres@localhost:5432/gis_test
# libpq URL for the psql bootstrap step below.
PSQL_URL: postgresql://postgres:postgres@localhost:5432/gis_test
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- run: pip install -r requirements-dev.txt
# PostGIS ships the binaries; the EXTENSION must still be created
# in the specific database the tests connect to (gis_test).
- name: Enable PostGIS extension
run: psql "$PSQL_URL" -v ON_ERROR_STOP=1 -c "CREATE EXTENSION IF NOT EXISTS postgis"
# Apply schema/migrations before seeding.
- name: Migrate
run: alembic upgrade head
- name: Run integration tests
run: pytest tests/integration -qThe matching pytest fixture creates the engine, guarantees the extension and schema, and seeds a geometry with an explicit SRID — the fixture is the geometry:
# tests/integration/conftest.py
import os
import pytest_asyncio
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
DATABASE_URL = os.environ["DATABASE_URL"] # postgresql+asyncpg://...@localhost:5432/gis_test
@pytest_asyncio.fixture(scope="session")
async def engine():
eng = create_async_engine(DATABASE_URL)
async with eng.begin() as conn:
# Idempotent belt-and-braces in case the workflow step is skipped locally.
await conn.execute(text("CREATE EXTENSION IF NOT EXISTS postgis"))
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS zones (
id bigserial PRIMARY KEY,
name text,
geom geometry(Polygon, 4326) -- SRID is part of the column type
)
"""))
await conn.execute(
text("CREATE INDEX IF NOT EXISTS idx_zones_geom ON zones USING GIST (geom)")
)
yield eng
await eng.dispose()
@pytest_asyncio.fixture
async def session(engine):
Session = async_sessionmaker(engine, expire_on_commit=False)
async with Session() as s:
# Explicit SRID 4326 — omitting ST_SetSRID/SRID here is the #1 cause of
# tests that pass locally but return empty sets against real data.
await s.execute(text("""
INSERT INTO zones (name, geom) VALUES
('unit-square', ST_GeomFromText(
'POLYGON((0 0, 0 1, 1 1, 1 0, 0 0))', 4326))
"""))
await s.commit()
yield s
await s.execute(text("TRUNCATE zones RESTART IDENTITY"))
await s.commit()A test that only a real PostGIS can satisfy:
# tests/integration/test_zones.py
import pytest
from sqlalchemy import text
@pytest.mark.asyncio
async def test_point_within_zone(session):
result = await session.execute(text("""
SELECT name FROM zones
WHERE ST_Within(ST_SetSRID(ST_MakePoint(0.5, 0.5), 4326), geom)
"""))
assert result.scalar() == "unit-square"This same bounding-box-and-predicate shape is the workload described in implementing ST_Within and ST_Intersects in FastAPI — the integration test verifies that endpoint’s query against the real engine.
Key parameters & options
| Parameter | Where | Purpose |
|---|---|---|
image: postgis/postgis:16-3.4 | services.postgis | The pinned PostGIS build; must equal the deploy tag |
POSTGRES_DB: gis_test | service env | Creates the database the tests connect to |
ports: ["5432:5432"] | service | Maps the container port so runner steps reach localhost:5432 |
--health-cmd "pg_isready ..." | options | Readiness probe the runner waits on before steps start |
--health-interval / --health-retries | options | How often and how many times to probe before failing |
--health-start-period 10s | options | Grace window before failing probes count against retries |
DATABASE_URL (host localhost) | job env | asyncpg connection string for app + tests |
CREATE EXTENSION IF NOT EXISTS postgis | step | Registers ST_ functions in gis_test |
ON_ERROR_STOP=1 | psql flag | Fails the step loudly if the extension cannot be created |
Gotchas & failure modes
function st_within(...) does not exist— the extension was not created, or was created in the wrong database. The image auto-creates it only in the defaultPOSTGRES_DBon some tags and versions; do not rely on that. RunCREATE EXTENSION IF NOT EXISTS postgisexplicitly againstgis_test, withON_ERROR_STOP=1so a failure is not swallowed.could not connect to server: Connection refused— steps started before PostgreSQL accepted connections. The container reports “started” before the server is ready. Never substitute a fixedsleep; use--health-cmd "pg_isready -U postgres -d gis_test"with retries so the runner blocks step execution until the probe passes.getaddrinfo ... Name or service not knownfor hostpostgis— you used the service name as the hostname. When your steps run directly on the runner (the default here), the service is reachable atlocalhoston the mapped port, not by service name. The service name resolves only when the job itself runs inside a container (container:at job level) sharing the service network.Tests pass but production returns nothing — a fixture inserted geometry with
SRID=0(noST_SetSRID/ST_GeomFromText(..., 4326)). Mixed-SRID comparisons raiseOperation on mixed SRID geometriesor silently skip the GiST index. Bake the SRID into the column type —geometry(Polygon, 4326)— and into every insert.pg_isreadypasses butCREATE EXTENSIONstill races on a slow runner —pg_isreadyreports the postmaster is accepting connections, which is enough here, but on heavily loaded runners add--health-start-periodto avoid early probe failures burning your retry budget before the server is warm.
Verification
Confirm the service, the extension, and the version from inside the job:
# Extension is present in the DB the tests use
psql "$PSQL_URL" -c "SELECT extname, extversion FROM pg_extension WHERE extname='postgis';"
# It is the pinned build, not a surprise upgrade
psql "$PSQL_URL" -c "SELECT PostGIS_Version();"
# The seeded geometry has the SRID you expect
psql "$PSQL_URL" -c "SELECT name, ST_SRID(geom) FROM zones;" name | st_srid
-------------+---------
unit-square | 4326
An st_srid of 0 means a fixture forgot to set the SRID — fix it before trusting a single spatial assertion.
Related
- CI/CD Pipelines for Spatial APIs — the full pipeline this test job plugs into
- Automating Spatial Database Migrations in CI — the
alembic upgrade headstep, done safely with GeoAlchemy2 - Deploying & Operating Geospatial APIs — where the tested image is built, shipped, and run
← Back to CI/CD Pipelines for Spatial APIs