← Back to GeoJSON vs GeoParquet Serialization
This page shows how to serve a very large feature collection as a FlatGeobuf stream, with constant memory on the server and a client that can start reading before the query has finished.
Context & When to Use
A bulk export endpoint that builds its response in memory has a hard ceiling. Serialising 800 000 polygons to GeoJSON produces roughly 1.4 GB of text; the server holds all of it, the client parses all of it before showing anything, and a worker that does this twice concurrently is out of memory. The usual mitigation — paginate the export — pushes the problem onto the consumer, who now has to stitch 400 pages together and handle a cursor that may drift.
FlatGeobuf is designed for exactly this shape. It is a flat binary format with a header describing the schema, followed by features that can be read one at a time, so both writer and reader work in constant memory. Combined with a server-side cursor in PostgreSQL, the whole path from disk to socket streams: no stage ever holds more than a chunk.
Reach for it when a single response legitimately contains more features than a client should hold in memory at once, and when the consumer is a GIS tool or data pipeline rather than a browser. For interactive map traffic, vector tiles are the better answer, and for analytical consumers the columnar layout compared in GeoJSON vs GeoParquet Serialization may serve better still.
Runnable Implementation
from typing import Annotated, AsyncIterator
import asyncpg
from fastapi import APIRouter, Depends, Query
from fastapi.responses import StreamingResponse
router = APIRouter(prefix="/v1/exports", tags=["exports"])
CHUNK = 5_000 # features encoded per yield
EXPORT_SQL = """
SELECT id, layer, category_code, ST_AsBinary(geom) AS wkb
FROM features
WHERE geom && ST_MakeEnvelope($1, $2, $3, $4, 4326)
ORDER BY id
"""
async def flatgeobuf_stream(
pool: asyncpg.Pool, bbox: tuple[float, float, float, float]
) -> AsyncIterator[bytes]:
"""Yield a FlatGeobuf file in chunks, holding at most CHUNK features."""
writer = FlatGeobufWriter( # thin wrapper over the fgb encoder
geometry_type="MultiPolygon",
columns=[("id", "long"), ("layer", "string"), ("category_code", "int")],
crs=4326,
)
yield writer.header() # magic bytes + schema, before any feature
conn = await pool.acquire()
tx = conn.transaction()
await tx.start() # a cursor requires a transaction
try:
cursor = await conn.cursor(EXPORT_SQL, *bbox)
buffer = bytearray()
while True:
rows = await cursor.fetch(CHUNK)
if not rows:
break
for r in rows:
buffer += writer.feature(
wkb=r["wkb"],
values=(r["id"], r["layer"], r["category_code"]),
)
yield bytes(buffer)
buffer.clear() # constant memory: one chunk at a time
finally:
# Without this, an aborted download leaks a connection AND a transaction
await tx.rollback()
await pool.release(conn)
@router.get("/features.fgb")
async def export_features(
bbox: Annotated[str, Query(description="minx,miny,maxx,maxy in EPSG:4326")],
pool: asyncpg.Pool = Depends(get_pool),
) -> StreamingResponse:
minx, miny, maxx, maxy = (float(v) for v in bbox.split(","))
return StreamingResponse(
flatgeobuf_stream(pool, (minx, miny, maxx, maxy)),
media_type="application/vnd.flatgeobuf",
headers={
"Content-Disposition": 'attachment; filename="features.fgb"',
# No Content-Length is possible: the size is unknown until the end
"X-Accel-Buffering": "no", # stop nginx buffering the whole body
},
)The finally block is the load-bearing part. A client that closes the connection at 40 % — a user pressing cancel, a proxy timing out — cancels the generator, and without explicit cleanup the transaction stays open and the connection never returns to the pool.
Key Parameters & Options
| Setting | Value | Effect |
|---|---|---|
CHUNK | 5 000 features | Larger chunks reduce yields but raise peak memory and block the loop longer |
cursor.fetch() | server-side cursor | Without it asyncpg materialises the whole result set |
| Transaction | explicit, with finally | A cursor needs one; a leak here starves the pool |
X-Accel-Buffering: no | required behind nginx | Otherwise the proxy buffers the entire body and the streaming is lost |
Content-Disposition | attachment | Makes browsers save rather than attempt to render binary |
Content-Length | omitted | Unknowable mid-stream; chunked encoding instead |
Chunk size is the one number worth tuning. Too small and the overhead of yielding dominates; too large and each encode blocks the event loop long enough to delay other requests. Five thousand simple features is a good starting point — measure the encode time per chunk and keep it under about 20 ms.
What streaming actually buys
Where the bytes come from
Knowing the layout helps when a reader rejects the output, because the failure is almost always in the header rather than in the features.
Gotchas & Failure Modes
- A proxy that buffers. nginx and several managed load balancers buffer responses by default, which silently converts a streamed response back into a buffered one — on the proxy’s memory instead of yours.
X-Accel-Buffering: nohandles nginx; check the equivalent for your edge. - Leaked connections on client cancel. Without
finally, every cancelled download costs one connection and one open transaction until the server restarts. This exhausts the pool faster than any query, as described in Connection Pooling & PgBouncer Setup. - A long-lived transaction blocking vacuum. A ten-minute export holds a snapshot for ten minutes, during which dead tuples on the whole database cannot be reclaimed. Cap the export size, or run exports against a replica.
- Errors after the first byte. Once the response has started, the status code is already 200 and there is no way to signal failure except by truncating. Validate everything — parameters, permissions, geometry type — before the first
yield. - Mixed geometry types. FlatGeobuf’s header declares one geometry type. A query returning both polygons and points needs
Unknownas the declared type, which some readers handle poorly. Filter by type, or split the export. - No resumability. A failed download at 90 % starts again from zero. For very large exports, write to object storage and return a pre-signed URL, so the client’s HTTP range support does the resuming — the pattern in Async Bulk Uploads with Celery applied in reverse.
Verification Snippet
# First byte should arrive in well under a second, long before completion
curl -s -o /dev/null -w 'first_byte=%{time_starttransfer}s total=%{time_total}s size=%{size_download}\n' \
"http://localhost:8000/v1/exports/features.fgb?bbox=-1,50,1,52"
# first_byte=0.31s total=41.7s size=281018368
# The file must be readable by a standard GIS tool
ogrinfo -so -al /vsicurl/"http://localhost:8000/v1/exports/features.fgb?bbox=-1,50,1,52"
# Feature Count: 798412
# Geometry: Multi Polygonasync def test_memory_stays_flat(pool, monkeypatch):
import tracemalloc
tracemalloc.start()
total = 0
async for chunk in flatgeobuf_stream(pool, (-1, 50, 1, 52)):
total += len(chunk)
_, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
assert total > 10_000_000 # a real export happened
assert peak < 100 * 1024 * 1024 # and memory never grew with itRelated
- GeoJSON vs GeoParquet Serialization — choosing between the formats in the first place
- Best Practices for Serializing Large GeoJSON Responses — the same streaming argument for text output
- Connection Pooling & PgBouncer Setup — why a leaked streaming connection hurts so much
← Back to GeoJSON vs GeoParquet Serialization