Cloud-Native Geospatial Formats
Cloud-native formats are designed to be read in place over HTTP, a few bytes at a time, instead of downloaded whole. Cloud Optimized GeoTIFF (COG) does this for rasters; GeoParquet and FlatGeobuf do it for vectors; PMTiles does it for tiles. Together they let a pipeline query terabytes on object storage while pulling only the windows it needs. This is the storage-and-delivery stage of Geospatial Data Ingestion & Processing Workflows — the format you commit to after files land through Shapefile & GeoJSON Parsing and are normalized by Coordinate Reference System Transformations. It also feeds the engines in DuckDB Spatial Analytics and the delivery layer in Web Mapping & Interactive Visualization.
Architecture & Data Structures
Every cloud-native format solves the same problem in the same way: an internal index describes where each chunk of data physically lives, and clients fetch chunks with HTTP range requests (Range: bytes=8192-16383) rather than a full GET. The file lives on plain object storage — S3, Cloudflare R2, Google Cloud Storage, Azure Blob — with no database process and no tile server in front of it. All the intelligence about which bytes to ask for lives in the client library.
The four formats differ only in how they lay out data and index it:
- COG (Cloud Optimized GeoTIFF) is an ordinary GeoTIFF with two constraints: pixels are stored in internal tiles (typically 256×256 or 512×512) rather than scanline strips, and the file carries a pyramid of reduced-resolution overviews. The TIFF header holds
TileOffsets/TileByteCountsarrays that map every tile to a byte range, and the Image File Directories (IFDs) are laid out at the front so one small read locates every tile. - GeoParquet is Apache Parquet with a
geometadata key. Features are stored column-by-column and grouped into row groups (often 64–256 MB); each row group carries per-column statistics, including a bounding box, so a reader can skip whole groups that miss the query window. Geometry is encoded as WKB (or, in GeoParquet 1.1, native GeoArrow columns). - FlatGeobuf is a single-file binary of length-prefixed FlatBuffers features preceded by an optional packed Hilbert R-tree. The R-tree lets a client binary-search to the features intersecting a bounding box and range-request only those, making it the streaming-friendly counterpart to GeoParquet for feature-at-a-time access.
- PMTiles is a single-file tile archive: a header, a directory that maps each
z/x/ytile to a byte offset and length, and the tile blobs themselves. It replaces a whole MBTiles-plus-tile-server deployment with one static file.
Two more layouts round out the family. Zarr (and its geospatial profile, GeoZarr) breaks an n-dimensional array into a directory of independently-addressable chunks with a small JSON metadata document at the root — the natural home for time-series and multi-variable data cubes where COG's two-dimensional tiling runs out of dimensions, and the format behind most of the workflows in Xarray & rioxarray Raster Cubes. And STAC is not a data format at all but the catalogue layer above them: a JSON description of which assets exist, where, and over what footprint and time range, so a client can decide which COG to open before it opens anything. In a mature cloud-native stack, STAC answers "which files", the internal index answers "which bytes".
The invariant behind all of them is worth stating plainly, because it is what breaks when a deployment misbehaves: the format guarantees that a small, bounded read from a known location yields enough information to compute the byte range of everything else. That contract needs exactly two things from the storage layer — support for HTTP Range requests, and a 206 Partial Content response rather than a helpful 200 OK with the whole object. Nothing else about the server matters. A CDN that buffers and re-serves complete objects turns every cloud-native format back into a download, and no amount of client tuning recovers it.
A minimal COG open demonstrates the model — nothing downloads until you ask for pixels, and even then only the overlapping tiles transfer:
import rasterio
from rasterio.windows import Window
# Read a single 512x512 window from a COG on S3 — only that window transfers
cog_url = "https://example-bucket.s3.amazonaws.com/sentinel_ortho_cog.tif"
with rasterio.open(cog_url) as src:
print(src.profile["driver"], src.block_shapes[0]) # GTiff (512, 512) — internally tiled
window = Window(col_off=4096, row_off=4096, width=512, height=512)
patch = src.read(1, window=window)
print(patch.shape) # (512, 512) — the full image was never localized
The reciprocal for vectors is a row-group-aware read: the reader inspects Parquet footer statistics, discards groups whose bounding box misses the query, and decodes only the survivors.
Environment Configuration & Dependency Resolution
Cloud-native reads lean on GDAL's virtual filesystem layer (/vsis3/, /vsicurl/, /vsigs/, /vsiaz/), so the binding versions and their bundled GDAL matter more than usual. Install from conda-forge to keep GDAL, PROJ, and the Python bindings ABI-compatible:
conda install -c conda-forge \
"rasterio=1.3.*" "geopandas=0.14.*" "pyogrio=0.7.*" \
"pyarrow=15.*" "gdal=3.8.*" "duckdb=0.10.*"
pip install "pmtiles>=3.2" # PMTiles reader/writer, no GDAL dependency
Three things commonly bite here. GDAL below 3.1 cannot write COGs with the dedicated COG driver; GeoParquet round trips need pyarrow>=7 on both the writer and reader, and version skew between them is the single most common read failure; and pyogrio (not the older fiona path) is what pushes bounding-box filters down into GeoParquet and FlatGeobuf reads. The same rasterio install covered in Raster Data Handling with Rasterio provides COG read and write support out of the box.
For authenticated cloud reads, set the GDAL environment knobs so it issues efficient range requests instead of listing buckets or re-reading directories:
import rasterio
gdal_env = {
"AWS_S3_ENDPOINT": "s3.amazonaws.com",
"GDAL_DISABLE_READDIR_ON_OPEN": "EMPTY_DIR", # never LIST the bucket on open
"CPL_VSIL_CURL_ALLOWED_EXTENSIONS": ".tif", # skip sidecar probes (.ovr, .aux.xml)
"GDAL_HTTP_MULTIPLEX": "YES", # HTTP/2 multiplexed range requests
"VSI_CACHE": "TRUE", # cache fetched blocks in memory
}
with rasterio.Env(**gdal_env):
with rasterio.open("/vsis3/example-bucket/sentinel_ortho_cog.tif") as src:
overview_factors = src.overviews(1) # e.g. [2, 4, 8, 16] — decimation levels
GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR is the highest-impact setting: without it, GDAL LISTs the whole prefix on every open, which on a bucket with millions of objects turns a sub-second read into minutes. Credentials come from the standard AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY (or an attached IAM role); for public buckets set AWS_NO_SIGN_REQUEST=YES, and for buckets that bill the reader set AWS_REQUEST_PAYER=requester — omitting it on a requester-pays bucket produces a bare 403 that looks identical to a credentials problem.
Which capabilities you actually have is a property of the GDAL build, not just its version number, and this is where environments diverge most:
| Capability | Needs | How to check |
|---|---|---|
| Write a real COG in one pass | GDAL 3.1+ (COG driver) |
gdalinfo --formats | grep COG |
| Read/write Parquet and Arrow | GDAL 3.5+ built with Arrow | ogrinfo --formats | grep -i parquet |
| LERC raster compression | GDAL built with liblerc | gdalinfo --format GTiff | grep LERC |
/vsiaz/ Azure blob access |
GDAL 3.0+ | gdalinfo --formats and a test open |
| Spatial pruning in DuckDB | spatial + httpfs extensions |
SELECT * FROM duckdb_extensions() |
The Parquet row is the one that surprises people: a Linux distribution package of GDAL 3.8 may have no Parquet driver at all, while a conda-forge build of 3.6 does. The version number tells you nothing on its own, which is why the check belongs in your environment smoke test rather than in a README.
Two more knobs are worth knowing before you need them. VSI_CACHE_SIZE sets the per-file-handle byte cache (default 25 MB) and is what makes repeated reads of the same header free. CPL_VSIL_CURL_USE_HEAD=NO suppresses the HEAD request GDAL issues to learn an object's size, which is worth setting when your storage layer answers HEAD slowly or not at all — some signed-URL schemes sign the GET only.
Vectorized Operations & Core Workflow
The everyday shape of a cloud-native pipeline: keep the canonical analytical dataset as GeoParquet, query bounding-box windows with GeoPandas or DuckDB in place, and derive COGs and PMTiles as rendering artifacts when you need them. The windowed-raster recipe is expanded in Windowed Reads from Cloud Optimized GeoTIFF.
Writing GeoParquet is a one-liner that preserves the CRS and produces row-group statistics automatically:
import geopandas as gpd
# Normalize to a documented CRS, then write GeoParquet
parcels = gpd.read_file("parcels.gpkg").to_crs(epsg=4326)
parcels.to_parquet(
"parcels.parquet",
compression="zstd", # smaller than snappy at similar decode speed
geometry_encoding="WKB", # default; GeoArrow available in geopandas>=1.0
row_group_size=50_000, # tune so a group is ~64-128 MB for skip efficiency
)
# Read back only a bounding-box window — pyogrio/pyarrow prunes row groups by bbox
aoi = (7.60, 45.00, 7.80, 45.10) # xmin, ymin, xmax, ymax in EPSG:4326
window = gpd.read_parquet("parcels.parquet", bbox=aoi)
print(len(window), "features touched the window")
The same window read works against a remote URL — gpd.read_parquet("s3://bucket/parcels.parquet", bbox=aoi) — with s3fs installed, and only the intersecting row groups plus the footer transfer. For SQL-style analytics over the same file, DuckDB reads Parquet natively and pushes spatial predicates down; the join and aggregation patterns live in DuckDB Spatial Analytics:
import duckdb
con = duckdb.connect()
con.execute("INSTALL spatial; LOAD spatial; INSTALL httpfs; LOAD httpfs;")
# Query a remote GeoParquet without ever downloading the whole file
rows = con.execute("""
SELECT parcel_id, ST_Area(ST_Transform(geometry, 'EPSG:4326', 'EPSG:32632')) AS area_m2
FROM 's3://example-bucket/parcels.parquet'
WHERE ST_Intersects(geometry, ST_MakeEnvelope(7.60, 45.00, 7.80, 45.10))
ORDER BY area_m2 DESC
LIMIT 10
""").fetchall()
Once a store grows past a few tens of gigabytes, stop writing one file and write a partitioned dataset instead: a directory tree of Parquet parts under Hive-style key=value subdirectories. Partition pruning happens from the directory names alone, before any footer is parsed, so it is strictly cheaper than row-group skipping and composes with it. Partition on whatever your queries filter by most — region, acquisition date, administrative level — and keep each part in the hundreds of megabytes.
import geopandas as gpd
# Read one partition out of s3://example-bucket/parcels/region=*/ without listing the rest
piemonte = gpd.read_parquet(
"s3://example-bucket/parcels/",
filters=[("region", "==", "piemonte")], # partition pruning, evaluated on paths
columns=["parcel_id", "land_use", "geometry"], # column pruning, evaluated on the footer
)
print(piemonte.crs, len(piemonte))
The vector counterpart for feature-at-a-time consumers is FlatGeobuf, whose packed R-tree answers a bounding-box query without a columnar engine at all. pyogrio pushes the box down into GDAL, so only the intersecting features are decoded:
import pyogrio
# The R-tree is read first; only features whose entry overlaps the box are fetched
sensors = pyogrio.read_dataframe(
"https://example-bucket.s3.amazonaws.com/sensors.fgb",
bbox=(7.60, 45.00, 7.80, 45.10), # in the file's own CRS
)
Note the comment: FlatGeobuf's bbox argument, like a raster Window, is interpreted in the file's CRS, not in whatever CRS you happen to be thinking in. That asymmetry with GeoParquet — where the covering columns are stored in the geometry column's declared CRS — is a recurring source of empty result sets.
Geometry / Data Processing Details
GeoParquet keeps geometry as WKB with CRS metadata (a PROJJSON block) in the file's schema, so a round trip is lossless — unlike Shapefile, which truncates field names to 10 characters, splits large layers at 2 GB, and demotes the CRS to a sidecar .prj that is easily lost. That fidelity is why GeoParquet is the right interchange format between processing stages rather than at the edges only; the full trade-off against legacy formats is worked through in GeoParquet vs Shapefile for Storage.
Two processing details determine whether cloud-native storage actually performs:
Spatial ordering drives skip efficiency. Row-group and R-tree pruning only help when nearby features sit near each other in the file. If features are written in random or insertion order, every row group's bounding box spans the whole dataset and no group can be skipped. Sort by a space-filling curve before writing:
import geopandas as gpd
from shapely import STRtree
buildings = gpd.read_parquet("buildings.parquet")
# Hilbert-curve ordering groups spatially-adjacent rows into the same row group
buildings = buildings.sort_values(
by="geometry",
key=lambda geom: geom.hilbert_distance(total_bounds=buildings.total_bounds),
)
buildings.to_parquet("buildings_sorted.parquet", row_group_size=50_000)
Overviews decide COG read cost at low zoom. A COG without overviews forces a full-resolution read even when the client only needs a thumbnail, so build a decimated pyramid at encode time. The rio cogeo tool does both the tiling and the overviews in one pass:
# Re-encode a plain GeoTIFF into a genuine COG: internal tiles + overviews
rio cogeo create dem_raw.tif dem_cog.tif \
--cog-profile deflate --overview-resampling average --blocksize 512
rio cogeo validate dem_cog.tif # confirms tiling + overviews, not just a renamed .tif
Compression is a per-format decision, not a global one. For GeoParquet, ZSTD at its default level is close to strictly better than Snappy: noticeably smaller files at comparable decode speed, and the decode is rarely the bottleneck when bytes travel over a network. For rasters, DEFLATE is the safe interoperable choice, LERC (with a controlled error bound) is dramatically smaller for continuous data like elevation, and JPEG is appropriate only for visual RGB imagery where lossy artefacts do not propagate into analysis. The failure mode is picking a raster codec the consumer's GDAL was not built with — a file that opens fine on your machine and raises a cryptic band-read error on theirs.
PMTiles is readable from Python, not only from a browser. That matters for validating a published archive without loading a web map:
from pmtiles.reader import Reader, MmapSource
with open("buildings.pmtiles", "rb") as f:
reader = Reader(MmapSource(f))
header = reader.header()
print(header["min_zoom"], header["max_zoom"]) # 0 14
tile = reader.get(12, 2200, 1345) # one z/x/y tile as raw bytes
print(len(tile) if tile else "no tile at that address")
A None here is diagnostic rather than an error: it means the archive genuinely has no tile at that address, usually because the source data did not cover it or the zoom range is narrower than the map requests.
For very large vector sources — global building footprints, road networks, administrative boundaries — never materialize the whole thing. Stream it feature-by-feature or partition-by-partition; the pattern for pulling continental extracts without exhausting memory is in Streaming Overture Maps Data with DuckDB.
CRS Alignment & Projection Pipeline
Cloud-native vector formats embed CRS metadata, so the discipline is to tag data correctly at write time and reproject deliberately at read time — never to leave a file's CRS ambiguous. GeoParquet stores a PROJJSON CRS in the geo metadata; COGs store theirs in GeoTIFF geokeys; both survive a round trip. Align projections with Coordinate Systems with PyProj, and keep one rule front of mind: reproject the windowed result, not the whole dataset, so you never pay to transform data you did not read.
import geopandas as gpd
fields = gpd.read_parquet("fields.parquet")
print(fields.crs) # EPSG:4326 — read straight from file metadata
# Read a small window in the file's native CRS, then reproject ONLY that subset
subset = gpd.read_parquet("fields.parquet", bbox=(10.0, 45.0, 10.2, 45.2))
subset_utm = subset.to_crs(epsg=32632) # UTM 32N — a metric CRS, not Web Mercator
subset_utm["area_ha"] = subset_utm.geometry.area / 1e4 # areas are meaningful now
Two CRS traps specific to cloud reads. First, when you convert a geographic bounding box into a raster Window, the bounds must be in the raster's CRS — reproject them first with a pyproj.Transformer using always_xy=True, or the window lands in the wrong place. Second, do not use Web Mercator (EPSG:3857) for any metric result: its scale distortion grows with latitude, so an area or distance computed there is wrong by tens of percent away from the equator. Reproject windowed results into a local projected CRS (a UTM zone or a national grid) before measuring. PMTiles is the one place Web Mercator is correct — it is a rendering format whose tile grid is defined in EPSG:3857, and it is a display artifact, not an analytical one.
Production Export & Integration
Pick the output format by role, not by habit:
- GeoParquet as the canonical analytical store: columnar, ZSTD-compressed, CRS-aware, and queryable in place by DuckDB, GeoPandas, and the broader Arrow ecosystem. Partition large stores by region or date (Hive-style directory partitioning) so query engines prune whole partitions before touching row groups.
- COG for rasters: one file serves full-resolution windows and pre-built overviews to web clients and batch pipelines alike, replacing a separate tile pyramid and a WMS server.
- FlatGeobuf when a consumer needs to stream features in bounding-box order without a columnar engine — its packed R-tree makes single-file, feature-at-a-time reads cheap.
- PMTiles as the derived rendering artifact for Vector Tile Pipelines with PMTiles: a single static file replaces an MBTiles database and a running tile server, served straight from object storage with range requests.
A short pre-flight checklist before you publish:
- Validate cloud-readiness.
rio cogeo validate ortho_cog.tifconfirms a GeoTIFF is genuinely tiled with overviews, not a renamed strip TIFF. - Confirm row-group statistics. Inspect the Parquet footer (
pyarrow.parquet.ParquetFile(path).metadata) and check that row-group bounding boxes are tight — loose boxes mean the data was not spatially sorted before writing. - Set correct content types. Serve COGs as
image/tiff, PMTiles asapplication/octet-stream, and enable HTTP range support (Accept-Ranges: bytes) on the bucket, or clients silently fall back to full-file downloads. - Cross-check the CRS. Open the published file and assert its CRS matches what downstream stages expect before wiring it into the ingestion and processing workflow.
The cost model is requests plus egress, not storage
Object storage is cheap; the two lines that grow are per-request charges and per-gigabyte egress, and cloud-native formats move you decisively from the second to the first. A windowed COG read is a handful of GETs carrying megabytes where a download was one GET carrying gigabytes — an enormous egress win and a modest request-count cost. The pathological shape is the opposite: thousands of tiny scattered reads, each fetching far less than the minimum useful chunk, where you pay per request for bytes you barely use. That is exactly what an unaligned window loop or a 16 KB curl chunk size produces.
Three decisions follow from that. Prefer fewer, larger, block-aligned reads over many small ones. Put a CDN in front of anything read repeatedly, with long Cache-Control: max-age and a version in the filename so a rebuild never mixes old and new bytes in a client's cache — the deployment pattern described in deploying a Python map app behind a CDN. And check whether your storage provider charges egress at all: zero-egress object stores change the arithmetic enough that a format decision made on an egress-billed provider may not be the right one elsewhere.
Windows / Platform Edge Cases & Debugging
- A "windowed" remote read localizes the whole file. The GeoTIFF is not actually a COG (no internal tiling or overviews). Re-encode with
rio cogeo createand confirm withrio cogeo validate. - Slow S3 opens on large buckets. GDAL is LISTing the prefix. Set
GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIRandCPL_VSIL_CURL_ALLOWED_EXTENSIONS=.tifto stop directory scans and sidecar probes. pyarrowcannot read the Parquet. Writer/reader version skew — pinpyarrowto the same major version across the whole pipeline.- CRS is missing after a Shapefile round trip. Expected: the
.prjsidecar was dropped. Migrate to GeoParquet, which keeps the CRS inside the file. /vsicurl/or/vsis3/returns 403. Credentials or headers are unset. ConfigureAWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY(orAWS_NO_SIGN_REQUEST=YESfor public data), or passGDAL_HTTP_HEADERSfor token auth.- Row-group skipping does not help. Either the writer emitted no bbox statistics (upgrade the GeoParquet writer) or the features were never spatially sorted — Hilbert-sort before writing so nearby features share a row group.
- Windows path handling with
/vsimem/and UNC paths. GDAL virtual paths always use forward slashes; a backslash in a/vsis3/URL is treated as a literal object-name character and 404s. SSL certificate problem: unable to get local issuer certificatebehind a corporate proxy. GDAL's curl cannot see the intercepting proxy's root certificate. PointCURL_CA_BUNDLE(orSSL_CERT_FILE) at a bundle that includes it; disabling verification hides the problem and breaks in production.- PROJ database not found after an environment upgrade. PROJ 9 renamed the data-path variable from
PROJ_LIBtoPROJ_DATA. A stalePROJ_LIBleft in a shell profile silently points a new PROJ at an old grid directory, which is worse than no variable at all — reprojections succeed with the wrong datum shift. - Reads hang or crash under
multiprocessingon Linux. A GDAL dataset handle opened in the parent does not survivefork(). Open datasets inside the child process, or set the start method tospawn. - Long-path failures on Windows. Paths beyond 260 characters fail unless long-path support is enabled; cloud-native output trees with deep Hive partitioning hit this quickly. Shorten the partition keys rather than nesting further.
- A read works locally and 404s in CI. The local run picked up an ambient credential (an AWS profile, a logged-in CLI) that CI does not have. Test with the credential environment explicitly cleared to find these before deployment.
Frequently Asked Questions
Do I need all four formats, or can I standardise on one? Standardise on two: GeoParquet for analytical vector data and COG for rasters. Those cover the overwhelming majority of pipelines and both are readable by everything in the Python stack. Add PMTiles only when you are actually serving a web map, and FlatGeobuf only when a consumer needs streaming feature-at-a-time access without a columnar engine. Adding a format costs you a conversion step, a validation step, and a new failure mode, so each one should earn its place.
Is cloud-native worth it if my data lives on a local disk? Partly. The internal indexing still pays off — column pruning, row-group skipping, and overview reads are just as valuable against a local SSD, and GeoParquet is dramatically faster to read than a Shapefile of the same content regardless of where it sits. What you do not get is the range-request story, and on a fast local disk the difference between a windowed read and a full read narrows considerably. The strongest local argument is fidelity and size rather than speed.
How do I know whether a file someone sent me is genuinely cloud-optimized?
Validate it, do not trust the extension. rio cogeo validate reports whether a GeoTIFF is internally tiled with overviews or merely renamed. For GeoParquet, read the footer and check both the row-group count and whether the geo metadata declares a covering column — a single row group means no skipping is possible, and a missing covering declaration means a reader has nothing to prune with. Both checks belong in the ingestion step, not in a code review.
Should the storage CRS be Web Mercator so web maps are fast? No, except for tiles. Store analytical data in a CRS suited to measurement and let the rendering layer reproject; tile pyramids are the one artefact whose grid is defined in EPSG:3857, and they are derived outputs. Storing your canonical data in Web Mercator to save a reprojection means every area, length, and buffer computed from it is distorted, and the distortion grows with latitude. Reproject at the edge, not at the source — the projection discipline covered in Coordinate Systems with PyProj.
When does the whole cloud-native model stop being the right answer? When the workload is transactional rather than analytical. These formats are immutable: appending or editing means rewriting a file or a partition, which is fine for datasets refreshed on a schedule and hopeless for data edited row-by-row by concurrent users. At that point you want a database with real transactions and indexes — the trade-off examined in PostGIS Integration with Python. The two coexist happily: transact in the database, publish snapshots as cloud-native files.
Can I convert between these formats without losing anything? Between GeoParquet and FlatGeobuf, essentially yes — both keep full field names, real types, and an embedded CRS. Converting to tiles or to a raster is lossy by definition: PMTiles generation simplifies geometry per zoom level and drops attributes you did not ask it to keep, and rasterising vectors discards topology entirely. Treat those as one-way derivations from a canonical GeoParquet or COG source, regenerated rather than round-tripped, which is how the pipeline in Generating PMTiles from GeoParquet is structured.