GIS & Geospatial Data (QGIS, GeoPandas, PostGIS) Module 8 – Spatial SQL with PostGIS
Serving GeoJSON, vector tiles and OGC APIs
Return RFC 7946 GeoJSON from SQL, build vector tiles with ST_AsMVT and ST_TileEnvelope, compare tile and feature servers, and expose PostGIS safely.
What you will learn
- Write SQL that returns RFC 7946 GeoJSON with the right CRS, precision and properties
- Generate Mapbox Vector Tiles with ST_AsMVT, ST_AsMVTGeom and ST_TileEnvelope
- Compare tile and feature servers such as Martin, pg_tileserv, pg_featureserv and QGIS Server
- Secure a spatial database for the web with read-only roles, parameterised queries and time limits
Before you start
What you’ll learn · Before you start
What you will learn
- Write SQL that returns RFC 7946 GeoJSON with the right CRS, precision and properties
- Generate Mapbox Vector Tiles with ST_AsMVT, ST_AsMVTGeom and ST_TileEnvelope
- Compare tile and feature servers such as Martin, pg_tileserv, pg_featureserv and QGIS Server
- Secure a spatial database for the web with read-only roles, parameterised queries and time limits
Before you start
On this page
Ask PostGIS for clinic C2 as GeoJSON and the answer holds the coordinates 504000, 2005000 and a crs member, because
the town is stored in UTM metres. A web map that follows RFC 7946 reads the first number as a longitude and finds
nothing it can draw.
Here you serve the town from PostgreSQL 18.6 with PostGIS 3.6.4 in the three forms web maps ask for: GeoJSON documents, vector tiles, and the OGC APIs that tile and feature servers put in front of the database.
GeoJSON for the web: transform to 4326, round, and build the collection in SQL
RFC 7946 fixes every GeoJSON coordinate to “a geographic coordinate reference system”, WGS 84 longitude and latitude,
and it dropped the crs member of the older format (RFC 7946, 4 and B.1).
ST_AsGeoJSON writes a short crs for any geometry that is not in EPSG:4326 and up to 9 decimals by default
(ST_AsGeoJSON), so serving GeoJSON from a projected table takes a
transform and a rounding:
geojson_session.py
"""GeoJSON from SQL: a point as stored and as RFC 7946 wants it, a row as a Feature, and whole collections."""
from pg_session import Session
with Session() as pg:
pg.run("python3 make_town.py")
pg.run("createdb town")
pg.run("psql -X -q -d town -f load_town.sql")
pg.psql("geojson.sql", db="town") Output
$ python3 make_town.py
wrote wards.csv (12 rows), households.csv (5,000 rows) and clinics.csv (8 rows)
$ createdb town
$ psql -X -q -d town -f load_town.sql
wards | households | clinics
-------+------------+---------
12 | 5000 | 8
(1 row)
$ psql -X -a -d town -f geojson.sql
-- Clinic C2 as stored: metres of UTM zone 43N, and a crs member that RFC 7946 no longer has.
SELECT ST_AsGeoJSON(geom) AS as_stored FROM clinic WHERE clinic = 'C2';
as_stored
----------------------------------------------------------------------------------------------------------
{"type":"Point","crs":{"type":"name","properties":{"name":"EPSG:32643"}},"coordinates":[504000,2005000]}
(1 row)
-- Transformed to longitude and latitude, rounded to 6 decimal places.
SELECT ST_AsGeoJSON(ST_Transform(geom, 4326), 6) AS for_the_web FROM clinic WHERE clinic = 'C2';
for_the_web
------------------------------------------------------
{"type":"Point","coordinates":[75.037813,18.133898]}
(1 row)
-- A whole row as a Feature: every other column becomes a property.
SELECT ST_AsGeoJSON(t.*, maxdecimaldigits => 6) AS feature
FROM (SELECT clinic, ST_Transform(geom, 4326) AS geom FROM clinic WHERE clinic = 'C2') AS t;
feature
-----------------------------------------------------------------------------------------------------------------------
{"type": "Feature", "geometry": {"type":"Point","coordinates":[75.037813,18.133898]}, "properties": {"clinic": "C2"}}
(1 row)
-- The 8 clinics as one FeatureCollection, assembled in the query.
SELECT json_build_object('type', 'FeatureCollection', 'features', json_agg(ST_AsGeoJSON(t.*, maxdecimaldigits => 6)::json))
->'features'->0 AS first_of_8,
length(json_build_object('type', 'FeatureCollection', 'features',
json_agg(ST_AsGeoJSON(t.*, maxdecimaldigits => 6)::json))::text) AS bytes
FROM (SELECT clinic, ST_Transform(geom, 4326) AS geom FROM clinic ORDER BY clinic) AS t;
first_of_8 | bytes
----------------------------------------------------------------------------------------------------------------------+-------
{"type": "Feature", "geometry": {"type":"Point","coordinates":[75.01418,18.135709]}, "properties": {"clinic": "C1"}} | 995
(1 row)
-- All 5,000 households: the size of the collection at 15, 9 and 6 decimal places.
SELECT d AS decimals, length(json_build_object('type', 'FeatureCollection', 'features',
json_agg(ST_AsGeoJSON(t.*, maxdecimaldigits => d)::json))::text) AS bytes
FROM (SELECT household, ST_Transform(geom, 4326) AS geom FROM household) AS t, (VALUES (15), (9), (6)) AS v (d)
GROUP BY d ORDER BY d DESC;
decimals | bytes
----------+--------
15 | 707344
9 | 653954
6 | 623883
(3 rows)
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 geojson_session.py
geojson.sql
-- Clinic C2 as stored: metres of UTM zone 43N, and a crs member that RFC 7946 no longer has.
SELECT ST_AsGeoJSON(geom) AS as_stored FROM clinic WHERE clinic = 'C2';
-- Transformed to longitude and latitude, rounded to 6 decimal places.
SELECT ST_AsGeoJSON(ST_Transform(geom, 4326), 6) AS for_the_web FROM clinic WHERE clinic = 'C2';
-- A whole row as a Feature: every other column becomes a property.
SELECT ST_AsGeoJSON(t.*, maxdecimaldigits => 6) AS feature
FROM (SELECT clinic, ST_Transform(geom, 4326) AS geom FROM clinic WHERE clinic = 'C2') AS t;
-- The 8 clinics as one FeatureCollection, assembled in the query.
SELECT json_build_object('type', 'FeatureCollection', 'features', json_agg(ST_AsGeoJSON(t.*, maxdecimaldigits => 6)::json))
->'features'->0 AS first_of_8,
length(json_build_object('type', 'FeatureCollection', 'features',
json_agg(ST_AsGeoJSON(t.*, maxdecimaldigits => 6)::json))::text) AS bytes
FROM (SELECT clinic, ST_Transform(geom, 4326) AS geom FROM clinic ORDER BY clinic) AS t;
-- All 5,000 households: the size of the collection at 15, 9 and 6 decimal places.
SELECT d AS decimals, length(json_build_object('type', 'FeatureCollection', 'features',
json_agg(ST_AsGeoJSON(t.*, maxdecimaldigits => d)::json))::text) AS bytes
FROM (SELECT household, ST_Transform(geom, 4326) AS geom FROM household) AS t, (VALUES (15), (9), (6)) AS v (d)
GROUP BY d ORDER BY d DESC; load_town.sql
-- The town in three typed tables, from the CSV files make_town.py writes (metres of UTM zone 43N).
CREATE EXTENSION postgis;
CREATE TABLE ward (ward text PRIMARY KEY, geom geometry(Polygon, 32643) NOT NULL);
CREATE TABLE household (household text PRIMARY KEY, geom geometry(Point, 32643) NOT NULL);
CREATE TABLE clinic (clinic text PRIMARY KEY, geom geometry(Point, 32643) NOT NULL);
CREATE TEMP TABLE ward_csv (ward text, wkt text);
\copy ward_csv FROM 'wards.csv' WITH (FORMAT csv, HEADER true)
INSERT INTO ward SELECT ward, ST_GeomFromText(wkt, 32643) FROM ward_csv;
CREATE TEMP TABLE point_csv (id text, x float8, y float8);
\copy point_csv FROM 'households.csv' WITH (FORMAT csv, HEADER true)
INSERT INTO household SELECT id, ST_Point(x, y, 32643) FROM point_csv;
TRUNCATE point_csv;
\copy point_csv FROM 'clinics.csv' WITH (FORMAT csv, HEADER true)
INSERT INTO clinic SELECT id, ST_Point(x, y, 32643) FROM point_csv;
CREATE INDEX ON ward USING gist (geom);
CREATE INDEX ON household USING gist (geom);
CREATE INDEX ON clinic USING gist (geom);
ANALYZE ward, household, clinic;
SELECT (SELECT count(*) FROM ward) AS wards, (SELECT count(*) FROM household) AS households,
(SELECT count(*) FROM clinic) AS clinics; make_town.py
"""Writes the track's town as three CSV files in metres of UTM zone 43N (EPSG:32643): the 12 wards as WKT polygons,
the 5,000 households and the 8 clinics as x and y. Python's standard library only."""
import csv
from town import clinics, households, ward_bounds
with open("wards.csv", "w", newline="") as f:
out = csv.writer(f)
out.writerow(["ward", "wkt"])
for ward, (x0, y0, x1, y1) in ward_bounds().items():
out.writerow([ward, f"POLYGON (({x0} {y0}, {x1} {y0}, {x1} {y1}, {x0} {y1}, {x0} {y0}))"])
for name, rows in (("households", households()), ("clinics", clinics())):
with open(f"{name}.csv", "w", newline="") as f:
out = csv.writer(f)
out.writerow([name[:-1], "x", "y"])
out.writerows(rows)
print("wrote wards.csv (12 rows), households.csv (5,000 rows) and clinics.csv (8 rows)") pg_session.py
"""Runs one session of this lesson on a throwaway PostgreSQL 18.6 server with PostGIS 3.6.4.
The session makes a new cluster in a folder of its own, starts it on 127.0.0.1 only (a port the operating system
reports as free, no Unix socket, a password made up for the run), runs psql scripts and the command-line tools of
PostGIS and GDAL against it, and prints each command with what it printed. Then it stops the server and deletes the
cluster, also when something fails. The tools read the connection from the standard libpq variables (PGHOST, PGPORT,
PGUSER, PGPASSWORD), so no command shows the port or the password.
The tools are looked up in the folder that PG_BIN names, then in the bin folder of a folder named postgis-toolchain in
the session's folder or in any folder above it, then on the PATH. To repeat a session on your own computer, install
PostgreSQL 18 with PostGIS 3.6 and GDAL 3.13, put their bin folder on the PATH and type the commands after "$ ".
The output is what the programs printed, with the session's folder shown as "." so that it reads the same on every
computer. A command that should succeed and fails, or a script that prints another number of ERROR lines than the
session expects, ends the session with status 1, so a run that went wrong is never kept as if it had worked.
"""
import os
import secrets
import shutil
import socket
import subprocess
import sys
import tempfile
from pathlib import Path
def _tool_folder():
"""The folder that holds postgres, psql and the other tools."""
named = os.environ.get("PG_BIN")
if named:
return Path(named)
here = Path.cwd().resolve()
for folder in (here, *here.parents):
candidate = folder / "postgis-toolchain" / "bin"
if (candidate / "postgres").exists():
return candidate
found = shutil.which("postgres")
if found:
return Path(found).resolve().parent
sys.exit("PostgreSQL with PostGIS was not found: set PG_BIN or put its bin folder on the PATH")
BIN = _tool_folder()
# A pipeline (shp2pgsql … | psql …) fails when any of its commands fails: bash's pipefail.
SHELL = shutil.which("bash") or "/bin/sh"
# Settings that make every run print the same: no parallel plans, no just-in-time compilation, no background vacuum.
SETTINGS = {
"jit": "off",
"max_parallel_workers_per_gather": "0",
"autovacuum": "off",
"timezone": "'UTC'",
"lc_messages": "'C'",
}
def _free_port():
"""A TCP port on 127.0.0.1 that nothing listens on now."""
with socket.socket() as probe:
probe.bind(("127.0.0.1", 0))
return probe.getsockname()[1]
class Session:
"""A cluster that exists for one with block: made and started on entry, stopped and deleted on exit."""
def __init__(self, settings=None):
self.settings = {**SETTINGS, **(settings or {})}
self.failed = False
def __enter__(self):
self.folder = Path.cwd()
self.base = Path(tempfile.mkdtemp(prefix="cluster-", dir=self.folder))
self.data = self.base / "data"
password = secrets.token_urlsafe(24)
(self.base / "pwfile").write_text(password + "\n")
self.env = {**os.environ, "PATH": f"{BIN}{os.pathsep}{os.environ.get('PATH', '')}", "PGHOST": "127.0.0.1",
"PGPORT": str(_free_port()), "PGUSER": "postgres", "PGPASSWORD": password,
"PGDATABASE": "postgres", "PGCONNECT_TIMEOUT": "10"}
for key in ("PGSERVICE", "PGSERVICEFILE", "PGOPTIONS", "PSQLRC", "PGSSLMODE"):
self.env.pop(key, None)
self._quiet(["initdb", "-D", str(self.data), "-U", "postgres", "--auth=scram-sha-256",
f"--pwfile={self.base / 'pwfile'}", "--no-locale", "--encoding=UTF8", "--no-instructions"])
with open(self.data / "postgresql.conf", "a") as conf:
conf.write(f"listen_addresses = '127.0.0.1'\nport = {self.env['PGPORT']}\nunix_socket_directories = ''\n")
for key, value in self.settings.items():
conf.write(f"{key} = {value}\n")
self._quiet(["pg_ctl", "-D", str(self.data), "-l", str(self.base / "server.log"), "-w", "-t", "60", "start"])
return self
def __exit__(self, *exc):
try:
self._quiet(["pg_ctl", "-D", str(self.data), "-m", "fast", "-w", "-t", "60", "stop"])
finally:
shutil.rmtree(self.base, ignore_errors=True)
if self.failed and exc[0] is None:
sys.exit(1)
def _quiet(self, args):
"""Runs one of the server's own programs without printing it; stops the session if it fails."""
done = subprocess.run([str(BIN / args[0]), *args[1:]], env=self.env, capture_output=True, text=True)
if done.returncode != 0:
sys.exit(f"{args[0]} failed: {done.stdout}{done.stderr}")
def _clean(self, text):
"""The output as it reads on any computer: the session's folder as "." and no folder of the tools."""
for folder in {str(self.folder), str(self.folder.resolve())}:
text = text.replace(folder + "/", "").replace(folder, ".")
return text.replace(str(BIN) + "/", "")
def run(self, command, status=0, note=None):
"""Runs a command line as a terminal would, prints it and its output, and checks how it ended."""
if note:
print(f"# {note}")
print(f"$ {command}")
done = subprocess.run([SHELL, "-c", f"set -o pipefail; {command}"], cwd=self.folder, env=self.env,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
out = self._clean(done.stdout)
if out.strip():
print(out.rstrip("\n"))
if done.returncode != status:
print(f"[exit status {done.returncode}; the session expected {status}]")
self.failed = True
print()
return out
def psql(self, script, db="postgres", errors=0, note=None):
"""Runs a psql script with every line echoed (-a) and checks how many ERROR lines it printed."""
out = self.run(f"psql -X -a -d {db} -f {script}", note=note)
found = sum(1 for line in out.splitlines() if "ERROR:" in line)
if found != errors:
print(f"[{script}: {found} errors; the session expected {errors}]")
self.failed = True
return out ST_AsGeoJSON(ST_Transform(geom, 4326), 6) gives [75.037813,18.133898]: six decimals are “about 10 centimeters”,
in the RFC’s words, finer than the town’s data. Given a whole row, ST_AsGeoJSON(t.*) returns a Feature and puts
every other column into properties, and json_agg inside json_build_object assembles a FeatureCollection in the
same query: 995 bytes for the 8 clinics.
| Households as one FeatureCollection | Bytes |
|---|---|
| 15 decimal places | 707,344 |
| 9 decimal places | 653,954 |
| 6 decimal places | 623,883 |
Serving GeoJSON for 5,000 points means sending 0.6 MB before the map shows anything, and most of it repeats the same structure for every feature. That is the size where tiles take over.
Key idea
For a web map, transform to EPSG:4326 and round to 6 decimals in the query, and send tiles instead of one document once a layer holds thousands of features.
Checkpoint
Check: GeoJSON for the web from SQL
A vector tile holds one tile’s features, in the tile’s own coordinates
A web map asks for tiles by zoom, column and row, z/x/y, the scheme of the
web map tiles lesson. vector tile: A map tile that holds named layers of features, with their geometry on an integer grid and their attributes, instead of a picture. The browser draws it with a style. More in “How web maps work: tiles and Web Mercator” carry features instead of
pictures, encoded in the Mapbox Vector Tile format. Three functions make one in SQL: ST_TileEnvelope(z, x, y)
returns the tile’s square in Web Mercator, ST_AsMVTGeom moves each geometry into the tile’s grid, its
tile extent: The number of integer units across one side of a vector tile's coordinate grid, usually 4096. Every coordinate in the tile is rounded to that grid. of 4,096 units across with a buffer of 256 by default (ST_AsMVTGeom), and the
aggregate ST_AsMVT encodes the rows as one layer (ST_AsMVT). Follow one
request through them:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The request. The map needs the tile at zoom 12, column 2901, row 1838, the one that holds clinic C2, and asks for it by its three numbers.
- The square. ST_TileEnvelope(12, 2901, 1838) returns the tile as a square in Web Mercator metres, from 8345700 to 8355484 east and 2044843 to 2054627 north.
- Rows in the square. The square is transformed once into the data's SRID, 32643, and ST_Intersects asks the GiST index for the households inside it: 3,288 of the 5,000.
- Into tile units. ST_AsMVTGeom moves each household into the tile's grid, 0 to 4,096 across from the top-left corner. H0001 lands at 2280, 966.
- One layer, encoded. ST_AsMVT aggregates the rows into a layer named households, with the household id as a property, and encodes the tile: 82,104 bytes.
- On the map. The map decodes the tile and draws its features with its own style, beside the tiles around it, each fetched the same way.
tile_session.py
"""One vector tile from SQL: the bounds of tile 12/2901/1838, its households encoded with ST_AsMVT, and one
household's place in tile units."""
from pg_session import Session
with Session() as pg:
pg.run("python3 make_town.py")
pg.run("createdb town")
pg.run("psql -X -q -d town -f load_town.sql")
pg.psql("tile.sql", db="town") Output
$ python3 make_town.py
wrote wards.csv (12 rows), households.csv (5,000 rows) and clinics.csv (8 rows)
$ createdb town
$ psql -X -q -d town -f load_town.sql
wards | households | clinics
-------+------------+---------
12 | 5000 | 8
(1 row)
$ psql -X -a -d town -f tile.sql
-- The zoom-12 tile that holds clinic C2: column 2901, row 1838. Its bounds, in Web Mercator metres.
SELECT ST_AsText(ST_TileEnvelope(12, 2901, 1838), 0) AS bounds_3857;
bounds_3857
--------------------------------------------------------------------------------------------
POLYGON((8345700 2044843,8345700 2054627,8355484 2054627,8355484 2044843,8345700 2044843))
(1 row)
-- The households of that tile in tile coordinates, encoded as one layer of a vector tile.
-- The tile's box is transformed into the data's SRID once, so the index on household.geom can answer.
WITH bounds AS (SELECT ST_TileEnvelope(12, 2901, 1838) AS b),
rows AS (
SELECT h.household, ST_AsMVTGeom(ST_Transform(h.geom, 3857), bounds.b) AS geom
FROM household h, bounds
WHERE ST_Intersects(h.geom, ST_Transform(bounds.b, 32643)))
SELECT count(*) AS features, length(ST_AsMVT(rows.*, 'households')) AS tile_bytes FROM rows;
features | tile_bytes
----------+------------
3288 | 82104
(1 row)
-- One household's position in tile units: 0 to 4096 across the tile, measured from its top-left corner.
SELECT h.household, ST_AsText(ST_AsMVTGeom(ST_Transform(h.geom, 3857), ST_TileEnvelope(12, 2901, 1838))) AS in_tile
FROM household h WHERE h.household = 'H0001';
household | in_tile
-----------+-----------------
H0001 | POINT(2280 966)
(1 row)
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 tile_session.py
tile.sql
-- The zoom-12 tile that holds clinic C2: column 2901, row 1838. Its bounds, in Web Mercator metres.
SELECT ST_AsText(ST_TileEnvelope(12, 2901, 1838), 0) AS bounds_3857;
-- The households of that tile in tile coordinates, encoded as one layer of a vector tile.
-- The tile's box is transformed into the data's SRID once, so the index on household.geom can answer.
WITH bounds AS (SELECT ST_TileEnvelope(12, 2901, 1838) AS b),
rows AS (
SELECT h.household, ST_AsMVTGeom(ST_Transform(h.geom, 3857), bounds.b) AS geom
FROM household h, bounds
WHERE ST_Intersects(h.geom, ST_Transform(bounds.b, 32643)))
SELECT count(*) AS features, length(ST_AsMVT(rows.*, 'households')) AS tile_bytes FROM rows;
-- One household's position in tile units: 0 to 4096 across the tile, measured from its top-left corner.
SELECT h.household, ST_AsText(ST_AsMVTGeom(ST_Transform(h.geom, 3857), ST_TileEnvelope(12, 2901, 1838))) AS in_tile
FROM household h WHERE h.household = 'H0001'; Tile 12/2901/1838 holds 3,288 of the town’s households and encodes them in 82,104 bytes. H0001 sits at 2280, 966 in
tile units, counted from the tile’s top-left corner. The query transforms the tile’s square into the data’s SRID once,
so the GiST index on household.geom answers the box question, instead of transforming 5,000 points to test them.
Servers sit between the map and the database. Martin is a tile server written in Rust that serves PostGIS tables and functions, PMTiles and MBTiles. pg_tileserv is a PostGIS-only tile server in Go. pg_featureserv, also in Go, serves features through OGC API - Features: A standard of the Open Geospatial Consortium for serving features over the web as collections that clients query with plain web requests; its core part is read-only.. QGIS Server publishes maps through WMS, WFS and OGC API for Features. OGC APIs are the OGC’s web standards for such services, and OGC API - Features covers querying spatial data on the web. A read-only role, parameters instead of pasted text, and a statement timeout keep such a database safe to expose. The database itself stays on a private network, where only the server in front of it can reach it.
Checkpoint
Check: Vector tiles from SQL
Tiles at three zooms, decoded, and a tile function with limits Pro: opens with a Pro, Premium or Ultimate pass
A tile’s size depends on what it holds and at which zoom. This part measures the town’s households at zooms 12 to 14, decodes a tile byte by byte in plain Python, and writes the tile function a server can call.
In this part:
- The same 5,000 households at zoom 12, 13 and 14
- What a tile holds, decoded with the standard library
- A tile function a tile server can call
2 runnable examples (decode_mvt.py, export_tile.sql, tilefn.sql, tilefn_session.py, zooms.sql, zooms_session.py) · about 4 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Tile and feature servers, and a database that is safe to expose Pro: opens with a Pro, Premium or Ultimate pass
Every public endpoint ends in a query on the database. This part compares four servers that run those queries for you, then makes the database safe to put behind them: a role that can only read, a time limit, values passed as parameters, and a server that only listens where it should.
In this part:
- Four servers, side by side
- A role that can only read, with a time limit
- Values as parameters, never pasted into the SQL
- A database that only listens where it should
1 runnable example (lookup.sql, safety.sql, safety_session.py, weak_lookup.py) · about 7 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Practise: check a FeatureCollection before it leaves
The exercise writes the check an endpoint should run on its own output, with the rules of this lesson: no crs
member, positions in degrees, rings that close.
Exercise · Medium · Python
Check a FeatureCollection before a web map gets it
An endpoint that serves GeoJSON should check its output before a web map receives it. Write problems(fc) in check_geojson.py. It takes a parsed GeoJSON object (a dict) and returns a sorted list of the problems it finds, each named once:
"not a FeatureCollection"when the top-level"type"is anything else;"crs member"when the collection, a feature or a geometry has a"crs"member, which RFC 7946 removed;"position out of range"when a longitude is outside −180 to 180 or a latitude outside −90 to 90, the sign of coordinates still in metres;"ring not closed"when a ring of aPolygonorMultiPolygondoes not end at the point where it starts.
Geometries are Point, LineString, Polygon and MultiPolygon. An empty list means the collection passed. The sample tests use the GeoJSON of this lesson's run.
Starter code · check_geojson.py
def problems(fc):
"""The RFC 7946 problems of a parsed FeatureCollection, sorted, each named once; [] when it passes."""
# Replace this line: check the type, every crs member, every position and every ring.
return [] The sample tests · test_check_geojson.py
from check_geojson import problems
AS_STORED = {"type": "FeatureCollection", "features": [{"type": "Feature", "properties": {"clinic": "C2"},
"geometry": {"type": "Point", "crs": {"type": "name", "properties": {"name": "EPSG:32643"}},
"coordinates": [504000, 2005000]}}]}
FOR_THE_WEB = {"type": "FeatureCollection", "features": [{"type": "Feature", "properties": {"clinic": "C2"},
"geometry": {"type": "Point", "coordinates": [75.037813, 18.133898]}}]}
WARD_W02 = {"type": "FeatureCollection", "features": [{"type": "Feature", "properties": {"ward": "W02"},
"geometry": {"type": "Polygon", "coordinates": [[[75.018906, 18.124862], [75.037812, 18.124859],
[75.037815, 18.142936], [75.018908, 18.142939], [75.018906, 18.124862]]]}}]}
def test_the_stored_point_has_two_problems():
"""finds the crs member and the coordinates in metres of the point as stored"""
assert problems(AS_STORED) == ["crs member", "position out of range"]
def test_the_web_versions_pass():
"""passes the point and the ward polygon transformed to longitude and latitude"""
assert problems(FOR_THE_WEB) == []
assert problems(WARD_W02) == []
def test_a_single_feature_is_not_a_collection():
"""reports a lone Feature where a FeatureCollection was expected"""
assert problems(FOR_THE_WEB["features"][0]) == ["not a FeatureCollection"]
def test_an_open_ring():
"""reports a polygon ring that does not end where it starts"""
ring = WARD_W02["features"][0]["geometry"]["coordinates"][0][:-1]
open_ward = {"type": "FeatureCollection", "features": [{"type": "Feature", "properties": {},
"geometry": {"type": "MultiPolygon", "coordinates": [[ring]]}}]}
assert problems(open_ward) == ["ring not closed"]
def test_metres_inside_a_multipolygon():
"""reports coordinates in metres inside a MultiPolygon too, here ward W02 as stored in UTM"""
in_metres = [[502000, 2004000], [504000, 2004000], [504000, 2006000], [502000, 2006000], [502000, 2004000]]
ward = {"type": "FeatureCollection", "features": [{"type": "Feature", "properties": {"ward": "W02"},
"geometry": {"type": "MultiPolygon", "coordinates": [[in_metres]]}}]}
assert problems(ward) == ["position out of range"] A hint
Write one helper that yields every position of a geometry: a Point's coordinates are one position, a LineString's a list of them, a Polygon's a list of rings, and a MultiPolygon's a list of polygons. Collect the problems in a set, then return sorted(...) of it.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Practice that adapts to you
Practise this lesson
6 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher): Why must GeoJSON for a web map be in longitude and latitude?
RFC 7946 defines one coordinate reference system for every GeoJSON coordinate: WGS 84 longitude and latitude in
decimal degrees, longitude first. It also dropped the crs member, so a reader cannot be told about another system.
A map that follows the RFC reads 504000, 2005000 as a longitude and a latitude and finds no place on Earth there.
Transform with ST_Transform(geom, 4326) before ST_AsGeoJSON, and round to about 6 decimals.
Serving data raises its own interview questions; these three start from this lesson, and the bank holds what each tests and its first step:
-
Mid to senior
A city map serves 5 million address points as vector tiles straight from PostGIS. Tiles at low zoom weigh over a megabyte and the map stutters while panning. What do you measure first, and what do you change in the tile SQL?
-
Mid to senior
You must put a "clinics near me" endpoint backed by PostGIS on the public internet. What sits between the internet and the database, and which settings of the database and its roles do you change before launch?
-
Fresher to mid
A dashboard downloads 200,000 land parcels as one GeoJSON file of 90 MB before it draws anything. Users also want to click a parcel to see its details and to download a district's parcels. GeoJSON, vector tiles or OGC API - Features: which do you use for what?
Flashcards · 7 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
Why does ST_AsGeoJSON on a UTM table give GeoJSON that a web map cannot draw, and what is the fix?
It writes a short crs member and the UTM metres, which an RFC 7946 reader takes as longitude and latitude. Write ST_AsGeoJSON(ST_Transform(geom, 4326), 6) instead: degrees with 6 decimals, about 10 cm.
-
How do you build a GeoJSON Feature and a FeatureCollection in one PostGIS query?
ST_AsGeoJSON(t.*) on a whole row returns a Feature with every other column in properties, and json_agg inside json_build_object assembles the collection: 995 bytes for the 8 clinics.
-
When should a web map get tiles instead of one GeoJSON document?
Once a layer holds thousands of features: the 5,000 households took 0.6 MB as one FeatureCollection even at 6 decimals, most of it the same structure repeated for every feature.
-
Which three PostGIS functions build a vector tile, and what does each do?
ST_TileEnvelope(z, x, y) returns the tile's square in Web Mercator; ST_AsMVTGeom moves each geometry into the tile's grid, an extent of 4,096 units with a buffer of 256 by default; and the aggregate ST_AsMVT encodes the rows as one layer of a Mapbox Vector Tile.
-
Why transform the tile's square into the data's SRID, not the data into Web Mercator?
The square is transformed once, and the GiST index on the stored geometry can then answer the box question; transforming 5,000 points to test them would bypass the index. Tile 12/2901/1838 held 3,288 households in 82,104 bytes.
-
What do Martin, pg_tileserv, pg_featureserv and QGIS Server serve?
Martin, written in Rust, serves tiles from PostGIS tables and functions, PMTiles and MBTiles; pg_tileserv, in Go, tiles from PostGIS only; pg_featureserv, in Go, features through OGC API - Features; QGIS Server maps through WMS, WFS and OGC API for Features.
-
What keeps a PostGIS database safe behind a web map?
A read-only role, parameters instead of text pasted into queries, a statement timeout, and the database on a private network where only the server in front of it can reach it.
Key takeaways
- Serve GeoJSON in EPSG:4326 with about 6 decimals:
ST_AsGeoJSON(ST_Transform(geom, 4326), 6), a row as a Feature, andjson_aggfor the collection. - A vector tile is the features of one z/x/y square in a grid of 4,096 units, built with
ST_TileEnvelope,ST_AsMVTGeomandST_AsMVT; transform the tile’s square to the data’s SRID so the index answers. - Tile and feature servers such as Martin, pg_tileserv, pg_featureserv and QGIS Server put those queries behind web standards, OGC APIs among them.
Terms in this lesson
- OGC API - Features
- A standard of the Open Geospatial Consortium for serving features over the web as collections that clients query with plain web requests; its core part is read-only.
References
- RFC 7946: The GeoJSON Format (Internet Engineering Task Force (IETF))
- PostGIS: ST_AsGeoJSON (PostGIS Project Steering Committee)
- PostGIS: ST_AsMVT (PostGIS Project Steering Committee)
- PostGIS: ST_AsMVTGeom (PostGIS Project Steering Committee)
- PostGIS: ST_TileEnvelope (PostGIS Project Steering Committee)
- Mapbox Vector Tile Specification 2.1 (Mapbox)
- Martin tile server (MapLibre)
- pg_tileserv (Crunchy Data)
- pg_featureserv (Crunchy Data)
- OGC API - Features (Open Geospatial Consortium (OGC))
- QGIS Server Guide/Manual: introduction (QGIS Project)
- PostgreSQL 18 documentation: psql (SQL interpolation of variables) (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: the pg_hba.conf file (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: ALTER ROLE (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: statement_timeout (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: libpq command execution (PQexecParams) (The PostgreSQL Global Development Group)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress