!POSTGRES_USER=postgrestgis \
=password \
POSTGRES_PASS=postgrestgis \
POSTGRES_DB=25432 \
POSTGRESTGIS_PORT-d
docker compose up
# pause to make sure the docker network gets up and running before we run any
# queries
!sleep 15s
PostgREST + PostGIS
How to set up a geospatial-capable REST API for your PostGIS database.
PostGIS, PostgREST, PostgreSQL
Introduction
PostgreSQL databases enabled with the PostGIS extensions are a great way to store and manage geospatial data. Managing database credentials for many users, however, can be cumbersome! If you want to provide easy, public (or authenticated), read-only access to information in any PostgreSQL database, it is possible to make a simple REST API using PostgREST. The API comes with some powerful table-querying capabilities out of the box but you can turn it into a geospatial API with user-defined SQL functions.
In this article I will walk through an example for setting up a PostGIS-enabled database and getting PostgREST running with a bounding box query endpoint. This process is basically identical to the one described in the first PostgREST tutorial but with a side of PostGIS sizzle. The example database + endpoint runs in a docker network that you can fire up locally for yourself. I based this workflow off of the docker-only setup in the PostgREST docs. All of the files used to get this system running are available on GitHub:
setup_db.sql
: SQL script for setting up the database that gets run every time we fire up the docker networkdocker-compose.yml
: docker compose file for firing up the docker network
Set up a database
To demonstrate the geospatial query possibilities of PostgREST, we need to set up a PostGIS-enabled database. This process is defined in setup_db.sql
which gets run every time we launch the docker network. For a real application, you probably would not want to initiate the database from scratch every time but it is convenient for this example.
add records to the plot
table
First, we create a single table called plot
that has an ID column (id
), longitude/latitude (lon
/lat
), and a geometry column (geom
):
-- create schema for a toy database
create schema api;
-- a little housekeeping
drop table if exists plot;
create extension if not exists postgis;
-- define the table schema
create table api.plot (
id serial primary key,
real not null,
lon real not null,
lat 4326)
geom geometry(Point, );
Once the table is set up we can generate 1000 random coordinates that fall in a bounding box that roughly covers CONUS. After the lon
/lat
values are populated, construct the geom
values using the PostGIS functions st_setsrid
and st_makepoint
:
--- add data (random points from a large bounding box)
insert into api.plot (lon, lat)
select random() * (-57.2183 - (-131.3702)) + -131.3702,
random() * (51.5376 - 11.9402) + 11.9402
from generate_series(1, 1000);
--- update geom column values
update api.plot
set geom = st_setsrid(st_makepoint(lon, lat), 4326);
--- add spatial index
alter table api.plot create index if not exists idx_plot_geom on api.plot using gist(geom);
set up credentials for API
The PostgREST API will access the database using a pre-defined user credential. The details for what you need for your specific use-case may vary, but this method (pulled straight out of the PostgREST docs) will generate a read-only user account that has select
privileges for the plot
table.
--- create roles for postgrest access
create role web_anon nologin;
grant usage on schema api to web_anon;
grant select on api.plot to web_anon;
create role authenticator noinherit login password 'mysecretpassword';
grant web_anon to authenticator;
define query_bbox
function
PostgREST contains a powerful querying API out-of-the-box, but if you want to use any of the spatial operations from the PostGIS functions you need to create user-defined functions that use them.
The function query_bbox
takes a set of bounding box coordinates (xmin
, ymin
, xmax
, ymax
) and returns all of the plot
records that fall within the bounding box:
--- function for querying plots by bounding box
create function api.query_bbox(xmin real, xmax real, ymin real, ymax real) returns setof api.plot as $$
select *
from api.plot
where st_contains(
st_makeenvelope(
query_bbox.xmin,
query_bbox.xmax,
query_bbox.ymin,
query_bbox.ymax,4326
),:geometry
plot.geom:
); $$ stable language sql;
Define the docker network
This docker network consists of two services: server
and db
. Wrapping the whole thing in a docker network is convenient for local testing but I would not recommend it for any kind of production deployment!
db
: PostGIS database
I used the kartoza/postgis
docker image and followed the docs to get this configuration. One helpful trick is to mount setup_db.sql
into the docker-entrypoint-initdb.d
directory to make it easy to initiate the database from a script. You can set up the docker-network.yml to use environment variables that might be shared across multiple services (i.e. server
). In this case, only POSTGRES_DB
gets used by both services.
IGNORE_INIT_HOOK_LOCKFILE
: to make sure thesetup_db.sql
script runs every time you launch the docker network (useful for debugging).
db:
image: kartoza/postgis:15-3.3
platform: linux/amd64
environment:
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASS=${POSTGRES_PASS}
- POSTGRES_DB=${POSTGRES_DB}
- ALLOW_IP_RANGE=0.0.0.0/0
- IGNORE_INIT_HOOK_LOCKFILE=true
ports:
- "${POSTGRESTGIS_PORT}:5432"
volumes:
- ./setup_db.sql:/docker-entrypoint-initdb.d/setup_db.sql
healthcheck:
test: "PGPASSWORD=${POSTGRES_PASS} pg_isready -h 127.0.0.1 -U ${POSTGRES_USER} -d ${POSTGRES_DB}"
Once db
is up and running, the username:password combo authenticator:mysecretpassword
is available to the PostgREST endpoint
server
: PostgREST server
Conveniently, there is a postgrest
docker image that can be configured to work with an existing database using environment variables only!
PGRST_DB_URI
: the database connection URI with the credentials that we set up insetup_db.sql
for the API connection (authenticator:mysecretpassword
)- note: the
host:port
for this connection references the location within the docker network (db:5432
) rather than some real network location, which is convenient in this case because the containerized database is already configured for access by other services within the docker network.
- note: the
PGRST_DB_SCHEMAS
: name(s) of schemas that PostgREST can usePGRST_DB_ANON_ROLE
: database role to be used by PostgREST, needs to match the one that we defined insetup_db.sql
PGRST_OPENAPI_SERVER_PROXY_URI
: URI for the database endpoint, set to use the same local port that we mapped usingports
server:
image: postgrest/postgrest
ports:
- "3000:3000"
environment:
PGRST_DB_URI: postgres://authenticator:mysecretpassword@db:5432/${POSTGRES_DB}
PGRST_DB_SCHEMAS: api
PGRST_DB_ANON_ROLE: web_anon
PGRST_OPENAPI_SERVER_PROXY_URI: http://127.0.0.1:3000
depends_on:
- db
Get it running
Build the docker network:
Now that the database and PostgREST server are running we can send queries to the database.
query using curl
The PostgREST API documentation has an extensive guide to interacting with the API, so definitely check that out.
This query will pull the first 10 records from the plot
table:
!curl "http://localhost:3000/plot?limit=10" -H "Accept: application/json" --silent
[{"id":1,"lon":-113.873116,"lat":21.728935,"geom":{"type":"Point","crs":{"type":"name","properties":{"name":"EPSG:4326"}},"coordinates":[-113.87311553955078,21.72893524169922]}},
{"id":2,"lon":-88.675735,"lat":39.96215,"geom":{"type":"Point","crs":{"type":"name","properties":{"name":"EPSG:4326"}},"coordinates":[-88.67573547363281,39.96215057373047]}},
{"id":3,"lon":-110.97329,"lat":35.708477,"geom":{"type":"Point","crs":{"type":"name","properties":{"name":"EPSG:4326"}},"coordinates":[-110.9732894897461,35.70847702026367]}},
{"id":4,"lon":-91.52903,"lat":46.138012,"geom":{"type":"Point","crs":{"type":"name","properties":{"name":"EPSG:4326"}},"coordinates":[-91.5290298461914,46.13801193237305]}},
{"id":5,"lon":-117.86506,"lat":50.94,"geom":{"type":"Point","crs":{"type":"name","properties":{"name":"EPSG:4326"}},"coordinates":[-117.86505889892578,50.939998626708984]}},
{"id":6,"lon":-111.28939,"lat":49.994484,"geom":{"type":"Point","crs":{"type":"name","properties":{"name":"EPSG:4326"}},"coordinates":[-111.28939056396484,49.994483947753906]}},
{"id":7,"lon":-62.85243,"lat":13.343661,"geom":{"type":"Point","crs":{"type":"name","properties":{"name":"EPSG:4326"}},"coordinates":[-62.8524284362793,13.343661308288574]}},
{"id":8,"lon":-126.50603,"lat":27.355698,"geom":{"type":"Point","crs":{"type":"name","properties":{"name":"EPSG:4326"}},"coordinates":[-126.50602722167969,27.355697631835938]}},
{"id":9,"lon":-129.49072,"lat":25.89924,"geom":{"type":"Point","crs":{"type":"name","properties":{"name":"EPSG:4326"}},"coordinates":[-129.49072265625,25.899240493774414]}},
{"id":10,"lon":-78.039024,"lat":33.68596,"geom":{"type":"Point","crs":{"type":"name","properties":{"name":"EPSG:4326"}},"coordinates":[-78.03902435302734,33.68595886230469]}}]
This query uses the API to select the row where id
is 1
:
# run a query to get id == 1
!curl "http://localhost:3000/plot?id=eq.1" -H "Accept: application/geo+json" --silent
{"type" : "FeatureCollection", "features" : [{"type": "Feature", "geometry": {"type":"Point","coordinates":[-113.87311554,21.728935242]}, "properties": {"id": 1, "lon": -113.873116, "lat": 21.728935}}]}
- note: we asked for the result to be returned as a GeoJSON with the
Accept
header!
Try writing data to the plot
table.
!curl http://localhost:3000/plot -X POST -H "Content-Type: application/json" \
-d '{"lon": -90.0, "lat": 45.0}' \
--silent
{"code":"42501","details":null,"hint":null,"message":"permission denied for table plot"}
It doesn’t work! That’s a good thing. We could have set up the API’s credentials with write privileges but we did not, so this is exactly what we want to see. It is possible to configure an endpoint with write privileges but I have not tried it yet.
query using Python
We can use the requests
library to send queries to PostgREST. This query uses the query_bbox
function that we defined in setup_db.sql
, which is conveniently set up as an endpoint in our API.
import requests
= (-95.9190, 45.7978, -90.6841, 48.1250)
bbox = requests.post(
r "http://localhost:3000/rpc/query_bbox",
={"xmin": bbox[0], "xmax": bbox[1], "ymin": bbox[2], "ymax": bbox[3]},
json={"Accept": "application/geo+json"},
headers )
- note: all user-defined functions get dropped under the
rpc/
prefix, see the docs for more info
Since we requested the data be returned in the GeoJSON format, we can load the returned data into a geopandas.GeoDataFrame
very easily:
import geopandas as gpd
= gpd.GeoDataFrame.from_features(r.json(), crs=4326)
gdf gdf
geometry | id | lon | lat | |
---|---|---|---|---|
0 | POINT (-91.52903 46.13801) | 4 | -91.529030 | 46.138012 |
1 | POINT (-94.91882 47.75245) | 25 | -94.918816 | 47.752453 |
2 | POINT (-93.55267 47.90618) | 339 | -93.552666 | 47.906178 |
3 | POINT (-94.52316 47.52239) | 924 | -94.523155 | 47.522390 |
4 | POINT (-93.37778 48.05107) | 955 | -93.377785 | 48.051070 |
Or, we can drop the GeoJSON directly into an interactive map using folium
:
import folium
import geojson_pydantic
= ((bbox[1] + bbox[3]) / 2, (bbox[0] + bbox[2]) / 2)
center
= folium.Map(location=center, zoom_start=7)
m
# add bounding box to map
= folium.GeoJson(
bbox_geojson =geojson_pydantic.Feature(
datatype="Feature",
={},
properties=geojson_pydantic.Polygon.from_bounds(*bbox)
geometrydict(exclude_none=True),
).=lambda x: {
style_function"opacity": 1,
"dashArray": "1",
"fillOpacity": 0,
"weight": 2,
},
)
bbox_geojson.add_to(m)
# add points to map
= folium.GeoJson(data=r.json())
plots_geojson
plots_geojson.add_to(m)
m
Conclusion
Hopefully this is enough to get you up and running with a REST API for your own PostGIS database! I highly recommend paging through the PostgREST documentation, so if you are left wondering about the possibilities for a REST API for your database, definitely check out the full documentation.