One FastAPI endpoint. Four spatial representations. The client picks.
GET /features -> GeoJSON FeatureCollection
GET /features Accept: application/vnd.apache.parquet -> GeoParquet 1.1
GET /features Accept: application/geo+json-seq -> newline-delimited GeoJSON
GET /features Accept: text/csv -> CSV with WKT geometry
GET /features?format=geoparquet -> explicit override
Same query, same route, same handler. No duplicated endpoints, no /features.parquet
twin, no result set held in memory.
geoparquet-fastapi-serializer is a response layer for FastAPI that turns rows of
spatial data into whichever representation the caller asked for:
- Content negotiation to RFC 9110 — real media ranges,
qvalues, wildcards, specificity-based tie-breaking, and406 Not Acceptablewith a body that tells the client what it could have asked for. A?format=query parameter overrides the header when you need a shareable link. - GeoParquet 1.1 output — ISO WKB geometry column plus a conformant
geometadata block carryingversion,primary_column,encoding,geometry_types, a full PROJJSONcrs,bbox,edges, and optionally the 1.1covering.bboxstruct column that makes cloud range-reads fast. - Genuine streaming — Parquet is written one row group at a time through a rolling buffer, GeoJSON is framed incrementally by hand, NDGeoJSON and CSV emit per row. Peak memory tracks your row-group size, not your result size. A million features serialise in the same ~30 MiB as a hundred thousand.
- Permissive input — rows may carry geometry as Shapely objects, WKB bytes, hex WKB,
WKT, GeoJSON mappings, or anything exposing
__geo_interface__. Rows themselves may be dicts, SQLAlchemyRows, named tuples, dataclasses or Pydantic models, from a list or an async cursor. - Correct OpenAPI — a
spatial_responses()helper documents every negotiated media type on the route, with a real GeoJSON FeatureCollection JSON Schema, so/docsand generated clients stop pretending the endpoint only speaksapplication/json.
A spatial API that only speaks GeoJSON is quietly expensive for everyone downstream.
GeoJSON is text. Every coordinate becomes a decimal string, every feature repeats every property name, and nothing compresses columnar-wise because nothing is columnar. In the benchmark below, 100,000 modest point features cost 20.9 MiB as GeoJSON and 3.8 MiB as GeoParquet — 5.5x — and the GeoJSON has to be parsed by a JSON parser on the other end before it becomes usable, while the Parquet can be memory-mapped, column- projected and row-group-skipped.
But you cannot simply switch to GeoParquet: a browser cannot render it, and a curl user
debugging your API wants to read the response. Both formats are correct answers to
different questions, which is exactly the problem HTTP content negotiation was designed
to solve — and which most spatial APIs solve instead by bolting a second endpoint onto
the first, doubling the surface area that has to stay in sync.
The other half of the problem is memory. The obvious implementation builds a list of features and hands it to a serialiser, which means peak memory scales with result size and one large export can take down the process. Parquet in particular looks un-streamable, because its footer holds metadata — including the bounding box of every geometry — that you only know once you have seen all the data. It turns out that footer is written last, so the metadata can be assembled during a single streaming pass and committed at close. That is what this library does.
Not published to any package index. Clone and install in editable mode:
git clone https://github.com/geospatial-api/geoparquet-fastapi-serializer.git
cd geoparquet-fastapi-serializer
python -m venv .venv && source .venv/bin/activate
pip install -e .Or vendor the package directly into your project — it is self-contained, with no imports outside its own tree:
cp -r src/geoparquet_fastapi /path/to/your/project/Then ensure fastapi, pyarrow>=14, shapely>=2 and pydantic>=2 are in your own
dependencies.
Requires Python 3.10+.
from fastapi import FastAPI, Request
from geoparquet_fastapi import SerializationOptions, SpatialResponse, spatial_responses
app = FastAPI()
ROWS = [
{"id": 1, "name": "Charing Cross", "geometry": "POINT (-0.1247 51.5074)"},
{"id": 2, "name": "Tower of London", "geometry": "POINT (-0.0759 51.5081)"},
]
@app.get("/features", responses=spatial_responses(), response_class=SpatialResponse)
async def features(request: Request) -> SpatialResponse:
return SpatialResponse(
ROWS,
request=request,
options=SerializationOptions(id_column="id"),
filename="landmarks",
cache_control="public, max-age=300",
)That is the whole integration. SpatialResponse reads Accept and ?format= off the
request, picks a representation, sets Content-Type, Content-Disposition and Vary,
and streams.
A runnable version with bbox filtering over 12 in-memory London landmarks lives in
examples/app.py:
uvicorn examples.app:app --reloadNothing needs to be in memory. Hand it an async generator:
from sqlalchemy import select
from geoparquet_fastapi import from_sqlalchemy_result
@app.get("/features", responses=spatial_responses(), response_class=SpatialResponse)
async def features(request: Request, session: AsyncSession = Depends(get_session)):
stmt = select(Feature.id, Feature.name, Feature.geom.label("geometry"))
async with session.stream(stmt) as result:
return SpatialResponse(from_sqlalchemy_result(result), request=request)SQLAlchemy is not a dependency of this package; from_sqlalchemy_result duck-types
the Result/AsyncResult API.
Implemented per RFC 9110 §12.5.1, which is subtler than the "split on comma, take the first" approach most APIs ship.
Recognised media types (canonical type first; aliases select the same representation
but are never echoed back as Content-Type):
| Representation | ?format= |
Canonical media type | Aliases |
|---|---|---|---|
| GeoJSON | geojson |
application/geo+json |
application/json, application/vnd.geo+json |
| GeoParquet | geoparquet |
application/vnd.apache.parquet |
application/x-parquet, application/parquet |
| NDGeoJSON | ndjson |
application/geo+json-seq |
application/x-ndjson, application/geojson-seq |
| CSV | csv |
text/csv |
— |
The rules, in order:
-
A non-empty
?format=wins outright. An explicit override in the URL is a stronger signal of intent than a header the client may not control, and it makes responses shareable as plain links. An unknown value is400, not406— it is a mistake in the URL, not a negotiation failure. -
A missing, empty or wholly unparseable
Acceptis treated as*/*. -
Each media range gets a quality from its
qparameter, defaulting to1, clamped to[0, 1]. Malformedqvalues fall back to1rather than turning a routine request into a406. -
The most specific matching range wins and supplies the quality. Specificity runs
type/subtype;param>type/subtype>type/*>*/*. This is the rule that surprises people:Accept: */*;q=0.9, application/geo+json;q=0.1 -> GeoParquet, not GeoJSONThe client said "anything at 0.9, but GeoJSON specifically at 0.1", so GeoJSON is deprioritised even though
*/*is listed first and scores higher. -
q=0means explicitly unacceptable, not merely least-preferred. -
The canonical media type is authoritative, because it is what will be sent. Aliases are consulted only when the client said nothing about the canonical type — so
Accept: application/jsonyields GeoJSON, butAccept: application/*;q=0.9, application/geo+json;q=0.1does not letapplication/jsonsmuggle the 0.9 back in. -
Remaining ties break by specificity, then by server preference order (GeoJSON, GeoParquet, NDGeoJSON, CSV). GeoJSON leads deliberately: it is what a browser sending
*/*should get, being the only one of the four that renders in a tab. -
If nothing is acceptable:
406, with a body listing what is on offer.
Individual malformed entries (json, text/, ;;;) are skipped rather than failing the
whole header.
$ curl -s localhost:8000/features -H 'Accept: application/xml' | jq
{
"detail": "no acceptable representation for Accept: 'application/xml'; server offers application/geo+json, application/vnd.apache.parquet, application/geo+json-seq, text/csv",
"supported_media_types": [
"application/geo+json",
"application/vnd.apache.parquet",
"application/geo+json-seq",
"text/csv"
],
"supported_formats": ["geojson", "geoparquet", "ndjson", "csv"]
}Every negotiated response carries Vary: Accept, since the same URL yields four different
bodies and caches must key on it.
All output below is real, produced by examples/app.py.
$ curl -s 'localhost:8000/features?bbox=-0.13,51.50,-0.09,51.52' \
-H 'Accept: application/geo+json' -D-
content-type: application/geo+json; charset=utf-8
content-disposition: attachment; filename="landmarks.geojson"
cache-control: public, max-age=300
vary: Accept
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"id": 1,
"geometry": { "type": "Point", "coordinates": [-0.1247, 51.5074] },
"properties": {
"name": "Charing Cross",
"category": "transport",
"visitors_2023": 34000000,
"rating": 4.1,
"opened": "1864-01-11",
"tags": { "zone": 1, "step_free": true }
}
},
{
"type": "Feature",
"id": 3,
"geometry": { "type": "Point", "coordinates": [-0.127, 51.5194] },
"properties": {
"name": "British Museum",
"category": "museum",
"visitors_2023": 5800000,
"rating": 4.7,
"opened": "1759-01-15",
"tags": { "free": true }
}
}
],
"bbox": [-0.127, 51.5007, -0.0759, 51.5194]
}Note the bbox member sits after features. RFC 7946 places no ordering constraint on
members, and deferring it is what allows the extent to be accumulated in the same single
streaming pass that writes the features.
$ curl -s 'localhost:8000/features?bbox=-0.13,51.50,-0.09,51.52&format=ndjson'
{"type":"Feature","id":1,"geometry":{"type":"Point","coordinates":[-0.1247,51.5074]},"properties":{"name":"Charing Cross","category":"transport","visitors_2023":34000000,"rating":4.1,"opened":"1864-01-11","tags":{"zone":1,"step_free":true}}}
{"type":"Feature","id":3,"geometry":{"type":"Point","coordinates":[-0.127,51.5194]},"properties":{"name":"British Museum","category":"museum","visitors_2023":5800000,"rating":4.7,"opened":"1759-01-15","tags":{"free":true}}}
One complete Feature per line, no enclosing collection. A consumer starts work on feature
1 without waiting for feature 1,000,000, a truncated transfer still yields every whole
line before the cut, and wc -l, split and jq -c all work directly.
$ curl -s 'localhost:8000/features?bbox=-0.13,51.50,-0.09,51.52&format=csv'
id,name,category,visitors_2023,rating,opened,tags,geometry
1,Charing Cross,transport,34000000,4.1,1864-01-11,"{""zone"":1,""step_free"":true}",POINT (-0.1247 51.5074)
3,British Museum,museum,5800000,4.7,1759-01-15,"{""free"":true}",POINT (-0.127 51.5194)
5,St Paul's Cathedral,landmark,1300000,4.6,1710-10-20,,POINT (-0.0983 51.5138)
6,Tate Modern,museum,4700000,4.5,2000-05-11,"{""free"":true}",POINT (-0.0995 51.5076)
11,Thames Path (central),route,,,,"{""length_km"":6.4}","LINESTRING (-0.1246 51.5007, -0.0983 51.5079, -0.0759 51.5081)"
Geometry as WKT in the final column; nested values as compact JSON; nulls as empty cells.
$ curl -s 'localhost:8000/features?format=geoparquet&covering_bbox=true' -o landmarks.parquet
$ python -c "import pyarrow.parquet as pq, json; \
print(json.dumps(json.loads(pq.ParquetFile('landmarks.parquet').metadata.metadata[b'geo']), indent=2))"
{
"version": "1.1.0",
"primary_column": "geometry",
"columns": {
"geometry": {
"encoding": "WKB",
"geometry_types": ["LineString", "Point", "Polygon"],
"crs": { "...": "full OGC:CRS84 PROJJSON" },
"edges": "planar",
"bbox": [-0.2944, 51.4769, -0.0005, 51.5313],
"covering": {
"bbox": {
"xmin": ["bbox", "xmin"],
"ymin": ["bbox", "ymin"],
"xmax": ["bbox", "xmax"],
"ymax": ["bbox", "ymax"]
}
}
}
}
}Arrow schema: ['id', 'name', 'category', 'visitors_2023', 'rating', 'opened', 'tags', 'bbox', 'geometry'].
The geometry column is placed last so a projected scan of the attributes never has to
skip a wide binary column first.
The one you usually want.
SpatialResponse(
content, # rows: list / iterable / async iterable
*,
request=None, # FastAPI Request; supplies accept + ?format=
accept=None, # or pass the header directly
format_override=None, # or the format token directly
representations=DEFAULT_REPRESENTATIONS,
options=None, # SerializationOptions
status_code=200,
headers=None,
filename=None, # sets Content-Disposition, extension auto-appended
disposition="attachment", # or "inline"
cache_control=None,
etag=None, # None | str | True (see below)
background=None,
)Passing accept=/format_override= instead of request= makes the response
unit-testable with no HTTP layer at all.
GeoJSONResponse, GeoParquetResponse, NDGeoJSONResponse and CSVResponse take the
same arguments minus the negotiation ones, and always emit their own format.
| Option | Default | Meaning |
|---|---|---|
geometry_column |
"geometry" |
Key in each row holding the geometry. |
id_column |
None |
Key promoted to the GeoJSON Feature id. Removed from properties in GeoJSON, retained as a normal column in Parquet/CSV. |
crs |
OGC_CRS84_PROJJSON |
PROJJSON object. None writes an explicit "unknown CRS". |
edges |
"planar" |
"planar" or "spherical". |
covering_bbox |
False |
Emit the GeoParquet 1.1 covering.bbox struct column. |
bbox_column |
"bbox" |
Name of that column. |
schema |
None |
Explicit pyarrow.Schema; bypasses inference. |
sample_size |
100 |
Rows sampled before the schema is frozen. |
row_group_size |
2048 |
Rows per Parquet row group, and the streaming chunk size. |
compression |
"snappy" |
Parquet codec. |
include_collection_bbox |
True |
Emit the GeoJSON collection bbox. |
from geoparquet_fastapi import negotiate
negotiate("text/csv;q=0.9, application/geo+json;q=0.8").name # 'csv'
negotiate("*/*").name # 'geojson'
negotiate(None, "geoparquet").name # 'geoparquet'Raises NotAcceptableError or UnsupportedFormatError. parse_accept() is exposed
separately if you want the parsed media ranges.
All inherit GeoSerializationError, so one handler catches everything:
| Exception | Raised when |
|---|---|
GeometryDecodeError |
A geometry value matches no supported input encoding. |
InvalidCRSError |
crs= is not a plausible PROJJSON object (e.g. "EPSG:4326"). |
SchemaInferenceError |
Column types cannot be unified, or a row is not a mapping. |
UnsupportedFormatError |
?format= named an unknown representation. |
NotAcceptableError |
Nothing on offer satisfies Accept. |
from geoparquet_fastapi import spatial_responses, format_query_parameter
@app.get(
"/features",
responses=spatial_responses(),
response_class=SpatialResponse,
openapi_extra={"parameters": [format_query_parameter()]},
)spatial_responses() lists every media type and alias under 200 — with a full RFC 7946
FeatureCollection JSON Schema (all seven geometry types, including recursive
GeometryCollection) for the GeoJSON variant, format: binary for Parquet, and worked
examples for the text formats — plus a documented 406. Pair it with
response_class=SpatialResponse so FastAPI stops advertising a default
application/json schema inferred from the return annotation.
Every representation streams, but they stream differently.
GeoParquet. The trick is that Parquet's footer — schema, row-group index and all
key/value metadata — is written last. A ParquetWriter writes into a small
non-seekable sink; after each row group the sink is drained and those bytes are yielded
to the client. Because the footer has not been written yet, the geo metadata (which
must carry the bbox of every geometry in the file) can be accumulated during the pass and
committed with add_key_value_metadata() just before close. Peak memory is one row group
in, one row group out.
GeoJSON. The FeatureCollection is framed by hand —
{"type":"FeatureCollection","features":[, then one encoded Feature at a time, then ],
the accumulated bbox, and }. Peak memory is proportional to the largest single
feature, not the collection.
NDGeoJSON / CSV. One chunk per row. CSV samples the head of the stream to fix its header, since CSV cannot grow a column mid-file.
The schema for Parquet is frozen from the first sample_size rows, because Parquet is
strongly typed and needs the schema before the first row group. Rows after the sample
window that introduce a new type for an existing column will raise rather than silently
corrupt the file — pass an explicit schema= when you know better than the sample.
row_group_size is the main tuning knob: it is both the Parquet row-group size and the
streaming chunk size. The default 2048 favours time-to-first-byte; production exports
usually want 50,000–150,000.
Streaming and strong ETags are genuinely in tension — an entity tag is a hash of the body, and the body does not exist until the stream ends, long after headers are sent. Rather than fake it:
etag=None(default) — stream, no ETag.etag="..."— send that literal tag (use when you can derive one from amax(updated_at)).etag=True— buffer the payload, hash it, and sendETagplusContent-Length.
etag=True is stable across processes and runs because the serialisers order everything
deterministically — notably geometry_types is sorted, since an unstable ordering there
would destabilise every Parquet ETag.
GeoParquet requires the crs field to be a PROJJSON object, not an EPSG:4326
string. Passing a string raises InvalidCRSError with a pointer to the right approach —
this is the single most common GeoParquet conformance mistake.
Two correct PROJJSON documents ship with the package:
OGC_CRS84_PROJJSON— WGS 84, longitude first. The GeoParquet default, what GeoJSON mandates, and the default here.EPSG_4326_PROJJSON— WGS 84, latitude first. Only use this if your coordinates really are stored latitude-first; emitting it over longitude-first data produces a file that reads back transposed.
For anything else, generate it with pyproj (not a dependency):
from pyproj import CRS
options = SerializationOptions(crs=CRS.from_epsg(27700).to_json_dict())crs=None writes an explicit JSON null, which in GeoParquet means "the producer does
not know the CRS" — meaningfully different from omitting the key, which would imply the
OGC:CRS84 default.
The crs is written out in full even for the default, because omitting it survives less
well through tools that rewrite metadata without preserving defaults.
Enabling covering_bbox=True adds a bbox struct column (xmin, ymin, xmax, ymax
as float32) and the matching covering entry in the geo metadata. This is what makes
cloud-native range reads fast: a reader consults Parquet's per-row-group statistics on
that column and skips entire row groups without fetching them.
Float32 is the spec's recommendation — the column is an index, and single precision halves its footprint. The values are rounded outward to the next representable float32, never to nearest. A covering box is a filter, so a box even one ULP inside its geometry would make a genuinely matching feature invisible.
Measured with benchmarks/serialize_bench.py on Python 3.12.13, pyarrow 25.0.0, shapely
2.1.2, Linux x86-64. Synthetic point features over Great Britain with seven mixed-type
attributes each (int id, string name, string category, int, float, bool, Point) —
deliberately not geometry-only rows, which would flatter Parquet's columnar compression
relative to real feature data. Best of 3 runs, row_group_size=50,000.
$ python benchmarks/serialize_bench.py
repeat=3 row_group_size=50,000
10,000 features (7 attributes + Point geometry)
------------------------------------------------------------------------------
format time size gzipped bytes/feat vs GeoJSON
GeoJSON 84ms 2.1 MiB 372.0 KiB 218B 1.00x
NDGeoJSON 61ms 2.1 MiB 371.9 KiB 218B 1.00x
CSV 52ms 894.4 KiB 318.0 KiB 92B 0.42x
GeoParquet 89ms 388.7 KiB 283.0 KiB 40B 0.18x
100,000 features (7 attributes + Point geometry)
------------------------------------------------------------------------------
format time size gzipped bytes/feat vs GeoJSON
GeoJSON 777ms 20.9 MiB 3.6 MiB 219B 1.00x
NDGeoJSON 608ms 20.9 MiB 3.6 MiB 219B 1.00x
CSV 520ms 8.8 MiB 3.1 MiB 93B 0.42x
GeoParquet 924ms 3.8 MiB 2.7 MiB 40B 0.18x
Reading these honestly:
- GeoParquet is 5.5x smaller uncompressed (20.9 MiB vs 3.8 MiB at 100k) — 40 bytes per feature against 219. That is the number that matters for egress cost and for clients on slow links.
- GeoParquet is slightly slower to serialise (924ms vs 777ms at 100k). Snappy compression and Arrow array construction are not free. You are trading a modest amount of server CPU for a large amount of bandwidth, and for a client that can consume the result without a JSON parse. If your bottleneck is CPU and your clients are on a LAN, GeoJSON is a defensible choice.
- After gzip the gap narrows sharply — 3.6 MiB vs 2.7 MiB, only 1.3x. If you already gzip everything at the edge, GeoParquet's wire-size win is much smaller than the raw numbers suggest. Its remaining advantages are parse cost, column projection and row-group skipping, not bytes on the wire.
- NDGeoJSON is the same size as GeoJSON but ~20% faster, because it never builds collection framing and each line is encoded independently.
- CSV is the fastest and smallest of the text formats, at the cost of losing types and nesting.
Peak resident memory while streaming GeoParquet, measured with tracemalloc, rows from a
generator and output chunks discarded as they arrive (row_group_size=50,000):
| Features | Peak |
|---|---|
| 10,000 | 5.6 MiB |
| 100,000 | 29.7 MiB |
| 1,000,000 | 29.7 MiB |
Memory is bounded by row-group size, not result size — 1M features cost exactly what 100k does. A buffering implementation would be roughly an order of magnitude higher at the last row.
Known and deliberate:
- Parquet schema is frozen from the first
sample_sizerows. A column whose type changes after the sample window raises rather than silently coercing. Passschema=when you know the shape up front. - CSV cannot grow columns mid-file. Keys first seen after the sample window are dropped, not appended, because appending would misalign every subsequent row.
etag=Truedisables streaming. By construction — see above.- No reprojection. Coordinates are written exactly as supplied; the
crsfield describes them, it does not transform them. Reproject in the database (ST_Transform) or withpyprojbefore serialising. - Only the
WKBGeoParquet encoding. The 1.1 native Arrow geometry encodings (point,linestring, …) are not written. WKB is universally readable; the native encodings are not yet. - 2D bounding boxes. Z ordinates round-trip correctly in the geometry column and are
reported in
geometry_types("Point Z"), butbboxandcovering.bboxare XY only. GeometryCollectionincovering.bboxuses the collection's overall envelope, which is correct but loose.- Decimal columns become
decimal128(38, scale)with the scale taken from the widest sampled value. A later value with more decimal places will fail rather than silently truncate. - No response compression. Use your ASGI server or reverse proxy — and note the gzip numbers above before deciding it is redundant.
- Streaming responses have no
Content-Length, so clients cannot show a progress bar. Useetag=Trueif you need one and can afford to buffer.
451 tests, no database, no network, no fixtures beyond in-memory data.
python -m venv .venv && source .venv/bin/activate
pip install -e '.[dev]'
python -m pytest -q451 passed, 1 warning in 4.58s
ruff check .
ruff format --check .All checks passed!
26 files already formatted
Coverage of note: ~90 table-driven negotiation cases (q-values, wildcards, specificity
conflicts, malformed headers, real browser Accept strings), GeoParquet 1.1 metadata
conformance, write/read round-trips asserting geometry equality via Shapely, PROJJSON
presence and axis order, bbox correctness across row groups, covering.bbox containment
under float32 rounding, bounded-memory streaming, lazy generator consumption, every
geometry input encoding, empty result sets, null geometries, mixed geometry types,
schema-inference edge cases, ETag stability, and end-to-end TestClient tests against
examples/app.py.
CI runs ruff plus the full suite on Python 3.10, 3.11, 3.12 and 3.13.
Background on the design decisions this library encodes:
- GeoJSON vs GeoParquet serialization — the format trade-off in more depth, and when each is the right answer.
- Core geospatial API architecture with FastAPI and PostGIS — where a response layer sits in the wider stack.
- OpenAPI schema generation for spatial types — documenting geometry in a way that generated clients respect.
- Async PostGIS transaction patterns — streaming result sets without holding a transaction open too long.
- Bounding box and spatial index queries — the query side of the bbox filtering shown in the example app.
- Redis caching for spatial queries — what to do with the ETag and
Cache-Controlhooks.
MIT — see LICENSE.