Short answer

A spatial data API serves geospatial features over HTTP so any client — a web map, a notebook, another service — can request exactly the subset it needs (by bounding box, attribute filter, or CRS) instead of downloading a whole shapefile and filtering locally. The modern standard is OGC API - Features, a REST/JSON successor to WFS, typically backed by PostGIS and fronted by a server like pygeoapi, pg_featureserv, or GeoServer. For visualization-scale data you pair it with vector tiles; for imagery and large raster catalogs you use STAC. Build an API when a dataset is consumed repeatedly by multiple clients; ship a file when it is a stable, one-time handoff.

OGC API - Features vs the old WFS

The Web Feature Service (WFS 1.1/2.0) worked but was XML-heavy, used GML by default, and required verbose GetFeature POST requests. OGC API - Features redesigns the same capability around plain REST and OpenAPI:

  • GET /collections — list available datasets.
  • GET /collections/{collectionId} — metadata for one dataset (extent, CRS, item count).
  • GET /collections/{collectionId}/items — the features, as GeoJSON.
  • GET /collections/{collectionId}/items/{featureId} — a single feature.

Querying is done with ordinary query parameters: ?bbox=minx,miny,maxx,maxy, ?limit=1000, ?datetime=2026-01-01/.., and attribute filters. The spec is modular: Part 1 (Core) is read-only; Part 2 adds CRS handling; Part 3 (Filtering) introduces CQL2 for expressions like ?filter=lithology='basalt' AND elevation>1500. Because every endpoint is described by an OpenAPI document at /api, clients can self-discover the schema.

This matters for GIS teams because GeoJSON-over-REST is consumable by MapLibre, deck.gl, Leaflet, Python (requests + geopandas.read_file), and R without an XML parser or a GIS desktop in the loop.

The layers of a real spatial data product

A folder of files is not a product. A maintained spatial data product separates concerns:

  1. Source ingestion — raw deliveries (CSV, shapefile, survey exports) landing in a staging area, with provenance recorded.
  2. Normalized storage — a PostGIS database where geometry is validated, reprojected to a known CRS, and indexed. This is the source of truth.
  3. Analysis / derived layers — materialized views or tables computed from the source.
  4. Publishing layer — OGC API - Features for vector queries, vector tiles for rendering, STAC for imagery, plain downloads for bulk.
  5. Metadata and versioning — collection descriptions, license, lineage, and an updated timestamp, so a consumer knows what they are pulling.

The payoff is repeatability: a new delivery flows through ingestion, the database updates, and every downstream client sees the new data without anyone re-exporting a shapefile by hand.

Worked example: PostGIS table to live API

Suppose you have a geology.outcrops table in PostGIS and want it queryable over HTTP.

  1. Prepare the table. Ensure a single, declared CRS and a spatial index:

    ALTER TABLE geology.outcrops
      ALTER COLUMN geom TYPE geometry(Point, 4326)
      USING ST_Transform(ST_SetSRID(geom, 4326), 4326);
    CREATE INDEX idx_outcrops_geom ON geology.outcrops USING GIST (geom);
    

    Storing geometry in EPSG:4326 makes GeoJSON output standards-compliant; OGC API - Features defaults its bbox and output CRS to CRS84 (lon/lat).

  2. Expose it. With pg_featureserv (a lightweight Go server from the PostGIS authors), point it at the database and it auto-publishes every table and view as a collection:

    DATABASE_URL="postgres://user:pw@host/db" pg_featureserv
    

    The outcrops table is now at /collections/geology.outcrops/items.

  3. Control what is exposed. Rather than publish raw tables, publish views that select only the public columns and rows. pg_featureserv also supports functions (/functions/...) for parameterized queries — for example a stored function returning outcrops within a buffer of a point.

  4. Query it. A web client requests only the current map extent:

    GET /collections/geology.outcrops/items?bbox=2.1,48.7,2.5,49.0&limit=2000
    

For a heavier feature set, pygeoapi (Python) adds CQL2 filtering and a richer provider model; GeoServer adds WMS/WMTS and styling if you also need rendered images.

Pagination and payload control

The single most common API failure is returning too many features at once. Defend against it:

  • Set a default and maximum limit (e.g. default 1000, hard cap 10000). Servers expose numberReturned and numberMatched plus next/prev links — clients should follow them rather than asking for everything.
  • For render-scale datasets (millions of features), do not page through Features at all — serve vector tiles (Mapbox Vector Tiles / ST_AsMVT in PostGIS, or a tiles endpoint). Features API is for queries and downloads; tiles are for drawing.
  • Use bbox filtering server-side so the database, with its GiST index, does the spatial cull — never ship the whole layer and filter in the browser.
  • Enable gzip/brotli compression; GeoJSON compresses well, often 80%+.
-- Vector tile generation in PostGIS
SELECT ST_AsMVT(t, 'outcrops') FROM (
  SELECT id, lithology,
         ST_AsMVTGeom(geom, ST_TileEnvelope(z, x, y)) AS geom
  FROM geology.outcrops
  WHERE geom && ST_TileEnvelope(z, x, y)
) AS t;

When NOT to build an API

An API is infrastructure you must run, monitor, and secure. Skip it when:

  • The dataset is stable and handed off once — ship a GeoPackage (one file, multiple layers, SQLite-based) or a cloud-optimized format (COG for rasters, GeoParquet/FlatGeobuf for vectors) the consumer reads directly from object storage.
  • The consumer needs the whole dataset anyway — paging it through an API is slower than a single download.
  • There is no recurring update — the maintenance cost of a service buys nothing.

Cloud-optimized files (COG, GeoParquet, PMTiles) increasingly blur this line: a static file on S3 with HTTP range requests gives much of the "query a subset" benefit without a running server.

Common pitfalls and why they happen

  • No declared CRS on the source. If PostGIS geometry has SRID 0, GeoJSON output is ambiguous and clients misplace it. Set the SRID explicitly with ST_SetSRID/ST_Transform.
  • Publishing raw tables. Exposing the storage schema directly leaks internal columns and couples your API to your database layout. Publish views or functions instead.
  • No spatial index. Without a GiST index, every bbox query scans the table; latency explodes as the table grows.
  • Ignoring license and lineage. A dataset served without provenance and redistribution terms is a liability the moment someone reuses it. Put license and source in the collection metadata.

QA and validation

  • Hit /conformance and confirm the server declares the OGC API classes you depend on.
  • Validate that returned GeoJSON parses (geopandas.read_file(url)), geometries are valid (ST_IsValid), and coordinates are in the expected lon/lat order.
  • Load-test a realistic bbox query and confirm the index is used (EXPLAIN ANALYZE).
  • Verify pagination links round-trip and numberMatched matches a direct COUNT.

Bathyl perspective

We design geological datasets as products, not exports: a versioned PostGIS source of truth, an OGC API - Features endpoint for queries, vector tiles for rendering, and metadata that travels with the data. The test is whether the next update flows through automatically and whether a consumer can see the lineage, CRS, and license without asking. An API is worth running only when reuse is real — otherwise a well-described file is the more honest deliverable.

Related reading

Sources