Scaffold a production-shaped FastAPI + PostGIS spatial API in one command, with the spatial parts already done properly.
create-geo-api generates a complete, runnable geospatial API project — not a
skeleton with TODO comments, but working code you can pip install -e .,
migrate and serve. Roughly 40–45 files depending on the options you pick:
- A layered application package.
api/holds HTTP concerns only,domain/holds spatial rules and query construction with no FastAPI imports (so it is unit-testable without an app),db/holds the engine and ORM models, andschemas/holds the wire contract kept deliberately separate from the ORM. - Async SQLAlchemy 2.0 + asyncpg + GeoAlchemy2, with
statement_cache_size=0set so the app does not break the day PgBouncer is put in front of it, and a server-sidestatement_timeoutso one enormous bounding box cannot pin a connection forever. - Strict Pydantic v2 geometry models. Longitude first, latitude second, range
checks on both, closed-ring validation for polygons, NaN/Infinity rejected —
and a transposed
[lat, lon]pair produces an error that says the coordinates look swapped rather than a generic range complaint. - Index-friendly bbox filtering. The generated query emits
geom && ST_MakeEnvelope(...)followed byST_Intersects(...): the cheap bounding-box operator selects candidates straight from the GiST index, and the exact predicate only runs on what survives. - Keyset pagination over
(created_at, id)with opaque, versioned, URL-safe cursors and a matching composite index in the initial migration. - k-nearest-neighbour search ordered by the
<->operator, so PostGIS walks the index in distance order and stops afterkrows instead of computing a distance for every row in the table. - GeoJSON FeatureCollection responses serialised by
ST_AsGeoJSONinside Postgres at 6-decimal precision, so geometries are never decoded into Python objects only to be re-encoded as JSON. - OpenAPI customised for spatial types — the document declares CRS84,
documents coordinate order explicitly, and defines reusable
GeoJSONPositionandBoundingBoxschemas. - A GiST index in the first migration, plus health/readiness endpoints, a
multi-stage
Dockerfile, adocker-compose.ymlwired topostgis/postgis, a GitHub Actions workflow with a PostGIS service container, and a test suite that passes offline.
Optionally, depending on the flags you pass: a projected analysis column
maintained by a trigger, API-key or JWT authentication with spatial scopes and
row-level security, on-demand ST_AsMVT vector tiles or a precomputed-tile bake
script, a Redis caching layer, and Alembic migrations.
The interesting decisions in a spatial API are made in the first hour and are painful to revisit: whether the bbox filter can use the index, whether pagination survives past page fifty, whether coordinate order is enforced or merely hoped for, whether the projected geometry is maintained by the database or by whichever code path happens to write the row. A generic FastAPI template has no opinion on any of them. This one does, and writes the reasoning into the generated code as comments so the next person can disagree on purpose rather than by accident.
Not published to PyPI — clone and install locally:
git clone https://github.com/geospatial-api/fastapi-postgis-starter.git
cd fastapi-postgis-starter
pip install -e .Or with uv:
uv venv --python 3.12 .venv
uv pip install -e ".[dev]"That puts create-geo-api on your path. Python 3.10+ is required.
create-geo-apiWith no arguments it prompts for everything:
$ create-geo-api
create-geo-api — scaffold a FastAPI + PostGIS service
Project name [Geo API]: Fleet Tracker
Python package slug [fleet_tracker]:
Description [A spatial API built on FastAPI and PostGIS.]: Vehicle telemetry
Author [Unknown Author]: Ada Lovelace
CRS handling:
1) 4326-only (default)
2) 4326-with-projected-analysis
choice: 2
Authentication:
1) none (default)
2) api-key
3) jwt-spatial-scopes
choice: 3
Tile strategy:
1) none (default)
2) db-mvt
3) precomputed-cdn
choice: 2
Add Redis caching layer? [y/N]: y
Add Dockerfile + docker-compose? [Y/n]:
Add Alembic migrations? [Y/n]:Every prompt has a flag, so the whole thing is scriptable:
$ create-geo-api --yes --name "Fleet Tracker" --auth api-key --tiles db-mvt ./ft
Wrote 42 files.
Generated Fleet Tracker in ft
Next steps:
cd ft
python -m venv .venv && . .venv/bin/activate
pip install -e ".[dev]"
cp .env.example .env # then set DATABASE_URL
docker compose up -d db # PostGIS on localhost:5432
alembic upgrade head # creates the table + GiST index
uvicorn fleet_tracker.main:app --reload
Then open http://127.0.0.1:8000/docs and try:
GET /v1/features?bbox=-0.2,51.4,0.05,51.6&limit=50
GET /v1/tiles/12/2047/1362.mvt
Auth: set FLEET_TRACKER_API_KEYS in .env and send a key as the X-API-Key header.
Run 'pytest -q' in the generated project to check the wiring.Follow those steps and the generated project's own suite passes immediately:
$ cd ft && pip install -e ".[dev]" && pytest -q
......s........................ [100%]
30 passed, 1 skipped in 0.13sThe one skip is the readiness check that needs a live PostGIS; it runs
automatically once DATABASE_URL is set.
create-geo-api [target] [options]
| Flag | Values | Default | Effect |
|---|---|---|---|
--name |
text | Geo API |
Human-readable project name. |
--package |
identifier | derived from --name |
Python package slug; must be a valid identifier. |
--description |
text | generic | Written into pyproject.toml and the OpenAPI description. |
--author |
text | Unknown Author |
Written into pyproject.toml. |
--crs |
4326-only, 4326-with-projected-analysis |
4326-only |
See below. |
--auth |
none, api-key, jwt-spatial-scopes |
none |
See below. |
--tiles |
none, db-mvt, precomputed-cdn |
none |
See below. |
--pagination |
cursor |
cursor |
Keyset only, on purpose. |
--projected-srid |
EPSG code | 3857 |
SRID of the projected analysis column. |
--redis / --no-redis |
— | off | Redis read-through cache for bbox and tile responses. |
--docker / --no-docker |
— | on | Dockerfile, docker-compose.yml, .dockerignore. |
--alembic / --no-alembic |
— | on | alembic.ini, migrations/, initial migration. |
-y, --yes |
— | — | Non-interactive; unspecified options take their defaults. |
--force |
— | — | Write into a non-empty directory. |
--dry-run |
— | — | Print the file tree, write nothing. |
--list-options |
— | — | Describe every option and exit. |
--version |
— | — | Print the version and exit. |
If target is omitted, the project is written to ./<package-slug>.
4326-only stores WGS84 longitude/latitude and nothing else. Simple, and correct
for anything that only ever filters and displays.
4326-with-projected-analysis adds a second geometry column in a metre-based
SRID, kept in sync by a BEFORE INSERT OR UPDATE trigger and given its own GiST
index. The trigger matters: maintaining the reprojection in application code
means rows inserted by psql, a COPY, or some future service silently miss it.
none leaves the API open — reasonable behind a gateway that already
authenticates.
api-key adds an X-API-Key dependency using hmac.compare_digest, and accepts
a comma-separated list of keys so rotation does not need a deployment window.
jwt-spatial-scopes adds bearer-token verification with explicit audience
checking and per-resource scopes (features:read, tiles:read), plus a
row-level-security module: the initial migration enables RLS on the table and
creates a tenant-isolation policy, and the runtime sets app.tenant_id as a
transaction-local GUC so the value cannot leak into the next request that borrows
the same pooled connection.
db-mvt generates GET /v1/tiles/{z}/{x}/{y}.mvt backed by ST_AsMVT, with
ST_TileEnvelope bbox pruning, an ETag, a CDN-friendly Cache-Control, and a
204 for empty tiles.
precomputed-cdn generates a redirect endpoint plus scripts/bake_tiles.py,
which walks only the tiles intersecting the data extent, skips empty ones, and
writes gzipped .mvt files ready to sync to object storage.
$ create-geo-api --yes --name "Fleet Tracker" --crs 4326-with-projected-analysis \
--auth jwt-spatial-scopes --tiles db-mvt --redis --dry-run
create-geo-api would write 45 files into /tmp/demo/fleet_tracker:
/tmp/demo/fleet_tracker
.dockerignore
.env.example
.github/
workflows/
ci.yml
.gitignore
Dockerfile
Makefile
README.md
alembic.ini
docker-compose.yml
migrations/
env.py
script.py.mako
versions/
0001_initial.py
pyproject.toml
src/
fleet_tracker/
__init__.py
api/
__init__.py
deps.py
errors.py
routes/
__init__.py
features.py
health.py
tiles.py
cache/
__init__.py
redis_cache.py
config.py
db/
__init__.py
models.py
session.py
domain/
__init__.py
bbox.py
features.py
pagination.py
tiles.py
main.py
openapi.py
schemas/
__init__.py
features.py
geometry.py
security/
__init__.py
jwt.py
rls.py
tests/
conftest.py
test_app.py
test_bbox.py
test_geometry_schema.py
test_pagination.pyThe scaffolder is intentionally small — about 800 lines across six modules — with no cookiecutter or copier dependency.
| Module | Responsibility |
|---|---|
options.py |
The ProjectOptions frozen dataclass: every choice, its validation, and its derived values (projected, has_auth, env_prefix, …). One source of truth shared by the CLI, the prompts and the tests. |
renderer.py |
A thin Jinja2 wrapper plus the filename conventions. StrictUndefined is on, so a typo in a template is an error rather than a silently blank line. |
scaffold.py |
MANIFEST: an ordered tuple of TemplateSpec(source, when) pairs. Conditional inclusion lives in plain Python predicates, not in template magic, which is what makes it directly testable. |
prompts.py |
The interactive flow, parameterised on input_fn/output_fn so tests drive it with a scripted list of answers. |
cli.py |
Argument parsing, the overwrite guard, --dry-run tree rendering and the "next steps" block. |
Templates live in src/fastapi_postgis_starter/templates/ and ship as package
data. Three filename conventions translate template paths to output paths:
| Template path | Output path |
|---|---|
pyproject.toml.j2 |
pyproject.toml |
src/__pkg__/main.py.j2 |
src/<slug>/main.py |
_dot_github/workflows/ci.yml.j2 |
.github/workflows/ci.yml |
Dotfiles are stored undotted (_dot_gitignore) so packaging tools and git
never quietly drop them from the built wheel.
- A non-empty target directory is refused unless
--forceis passed; a directory containing only.gitcounts as empty, so generating into a fresh clone works. - A target that exists as a plain file is refused outright.
--forcewrites over the generated files but leaves unrelated files alone.- The package slug is validated as a Python identifier before anything is written, so generation never half-fails on an unimportable name.
Generated modules carry a single # Docs: comment pointing at the guide that
explains the pattern in that file — the bbox module to the spatial-index-query
guide, the pagination module to the cursor-strategies guide, the RLS module to
the multi-tenant guide, and so on. One per module, and none of them are
load-bearing.
The scaffolder itself has no configuration file and reads no environment
variables — everything is a flag or a prompt. Configuration in the generated
project is environment-driven with a per-project prefix derived from the package
slug (fleet_tracker → FLEET_TRACKER_), documented in the generated
.env.example and README.
uv venv --python 3.12 .venv
uv pip install -e ".[dev]"
.venv/bin/python -m pytest -q$ .venv/bin/python -m pytest -q
........................................................................ [ 63%]
.......................................... [100%]
114 passed in 1.38s114 tests, no database and no network required. The suite covers:
- option validation, slug derivation, and the fact that every derived slug is importable;
- path-convention translation and
StrictUndefinedbehaviour; - generation across the auth × tiles × CRS matrix, asserting per-option that the right files exist and the wrong ones do not;
compileallover every generated.pyin each matrix cell;tomllibparsing of the generatedpyproject.tomlandyaml.safe_loadof the generated workflow and compose file;- golden-file snapshots of four key rendered outputs
(
UPDATE_SNAPSHOTS=1 pytest tests/test_snapshots.pyto refresh them); - an assertion that no Jinja placeholder ever survives into output;
- the overwrite guard,
--force,--dry-runequivalence, and CLI argument parsing including the negated boolean flags; - the interactive prompt flow, including re-prompting on an invalid slug.
Lint with ruff check . and ruff format --check ..
The patterns the generated project is built on are written up in more depth here:
- Core geospatial API architecture with FastAPI and PostGIS
- Spatial resource modeling patterns
- Spatial pagination and cursor strategies
- GeoJSON vs GeoParquet serialization
- Strict Pydantic validation for geometry
- Bounding box and spatial index queries
- K-nearest-neighbor routing algorithms
- Query plan analysis and index tuning
- Row-level security for multi-tenant PostGIS
- Containerizing PostGIS and FastAPI
MIT — see LICENSE.