PostgreSQL Out-of-Database Rasters
This guide explains how to serve raster tiles from VRT/COG files referenced in PostgreSQL, without storing actual pixel data in the database.
Why Out-of-Database?
| Approach | Storage | Performance | Use Case |
|---|---|---|---|
| In-DB Raster | Pixels in PostgreSQL | Heavy DB load | Analysis, small datasets |
| Out-DB Raster | Metadata + file paths | Fast, GDAL renders | Tile serving, large datasets |
Out-of-database rasters store only metadata and file paths in PostgreSQL. The actual pixel data stays in COG/VRT files on disk or cloud storage. This approach:
- Reduces database load - No pixel processing in PostgreSQL
- Leverages GDAL - Efficient tile rendering with reprojection
- Enables dynamic filtering - Query parameters passed to PostgreSQL function
- Supports mosaics - Multiple rasters composited per tile
Prerequisites
- PostgreSQL 11+ with PostGIS 3.0+
- Out-of-database rasters enabled:
SET postgis.enable_outdb_rasters = true; - COG or VRT files accessible from the server
- tileserver-rs built with
postgresandrasterfeatures
Configuration
[postgres]
connection_string = "postgresql://user:pass@localhost:5432/gis"
[[postgres.outdb_rasters]]
id = "imagery" # Also used as function name if 'function' is omitted
schema = "public"
# function = "get_raster_paths" # Optional: defaults to 'id' value
name = "Satellite Imagery"
minzoom = 0
maxzoom = 22
bounds = [-180.0, -85.0, 180.0, 85.0]
Configuration Options
| Option | Required | Description |
|---|---|---|
id | Yes | Unique source identifier (also used as function name if function omitted) |
schema | No | PostgreSQL schema (default: public) |
function | No | Function name that returns file paths (default: id value) |
name | No | Display name |
minzoom | No | Minimum zoom level (default: 0) |
maxzoom | No | Maximum zoom level (default: 22) |
bounds | No | Bounding box west, south, east, north |
resampling | No | Resampling method (default: bilinear) |
colormap | No | Colormap for single-band rasters |
PostgreSQL Function
Your function receives tile coordinates, a bounding box geometry, and query parameters as JSONB:
CREATE OR REPLACE FUNCTION get_raster_paths(
z INT,
x INT,
y INT,
tile_bbox GEOMETRY,
params JSONB
) RETURNS TABLE(filepath TEXT) AS $$
BEGIN
RETURN QUERY
SELECT r.file_path
FROM raster_catalog r
WHERE ST_Intersects(r.bounds, tile_bbox)
AND (params->>'satellite' IS NULL
OR r.satellite = params->>'satellite')
AND (params->>'date_from' IS NULL
OR r.acquired >= (params->>'date_from')::date)
AND (params->>'date_to' IS NULL
OR r.acquired <= (params->>'date_to')::date)
AND (params->>'cloud_cover' IS NULL
OR r.cloud_cover <= (params->>'cloud_cover')::float)
ORDER BY r.priority DESC, r.acquired DESC
LIMIT COALESCE((params->>'limit')::int, 5);
END;
$$ LANGUAGE plpgsql STABLE;
Function Signature
| Parameter | Type | Description |
|---|---|---|
z | INT | Zoom level |
x | INT | Tile X coordinate |
y | INT | Tile Y coordinate |
tile_bbox | GEOMETRY | Web Mercator (EPSG:3857) tile bounding box |
params | JSONB | All HTTP query parameters |
Return Value
The function must return a table with a filepath column containing paths to raster files.
API Usage
Basic Request
curl http://localhost:8080/data/imagery/10/512/256.png -o tile.png
With Filters
All query parameters are passed to your PostgreSQL function as JSONB:
curl "http://localhost:8080/data/imagery/10/512/256.png?satellite=sentinel-2&cloud_cover=10"
curl "http://localhost:8080/data/imagery/10/512/256.png?date_from=2024-01-01&date_to=2024-06-01"
curl "http://localhost:8080/data/imagery/10/512/256.png?satellite=landsat-8&latest=true"
VRT Support
VRT (Virtual Raster) files work seamlessly. Your function can return paths to .vrt files:
RETURN QUERY
SELECT '/data/mosaics/2024-q1.vrt'
WHERE ST_Intersects(mosaic_bounds, tile_bbox);
VRTs are powerful for:
- Mosaics - Combine multiple files into one virtual raster
- Band selection - Reference specific bands from multi-file datasets
- Overviews - Reference pre-built overview pyramids
Setting Up Out-DB Rasters
1. Enable Out-DB Rasters in PostGIS
ALTER SYSTEM SET postgis.enable_outdb_rasters = true;
SELECT pg_reload_conf();
Or in your session:
SET postgis.enable_outdb_rasters = true;
SET postgis.gdal_enabled_drivers = 'ENABLE_ALL';
2. Create a Raster Catalog Table
CREATE TABLE raster_catalog (
id SERIAL PRIMARY KEY,
file_path TEXT NOT NULL,
bounds GEOMETRY(Polygon, 4326),
satellite TEXT,
acquired DATE,
cloud_cover FLOAT,
priority INT DEFAULT 0
);
CREATE INDEX ON raster_catalog USING GIST(bounds);
CREATE INDEX ON raster_catalog(satellite);
CREATE INDEX ON raster_catalog(acquired);
3. Register Your Rasters
INSERT INTO raster_catalog (file_path, bounds, satellite, acquired)
SELECT
'/data/cogs/sentinel_' || id || '.tif',
ST_MakeEnvelope(west, south, east, north, 4326),
'sentinel-2',
acquisition_date
FROM raw_metadata;
Performance Tips
1. Spatial Indexing
Always create a GIST index on your geometry column:
CREATE INDEX ON raster_catalog USING GIST(bounds);
2. Dataset Caching
tileserver-rs caches opened GDAL datasets. Frequently accessed rasters stay in memory.
3. Use COGs
Cloud Optimized GeoTIFFs are faster than regular TIFFs:
gdal_translate input.tif output.cog.tif \
-of COG \
-co COMPRESS=ZSTD \
-co OVERVIEW_RESAMPLING=BILINEAR
4. Pre-build Overviews for VRTs
gdaladdo -r average mosaic.vrt 2 4 8 16
5. Limit Results
Always use LIMIT in your function to avoid returning too many files per tile.
Colormaps
For single-band rasters (DEMs, indices), configure a colormap:
[[postgres.outdb_rasters]]
id = "elevation"
function = "get_dem_paths"
[postgres.outdb_rasters.colormap]
map_type = "continuous"
nodata_color = "#00000000"
entries = [
{ value = 0, color = "#0000FF" },
{ value = 1000, color = "#00FF00" },
{ value = 3000, color = "#FF0000" },
{ value = 5000, color = "#FFFFFF" }
]
Colormap Options
| Option | Values | Description |
|---|---|---|
map_type | discrete, continuous | Discrete for exact matching, continuous for interpolation |
rescale_mode | static, dynamic, none | How to transform pixel values before colormap lookup |
nodata_color | Hex color | Color for nodata pixels (e.g., #00000000 for transparent) |
entries | Array | Value-to-color mappings |
Rescale Modes
| Mode | Description | Use Case |
|---|---|---|
static | Use raw pixel values directly (default) | Pre-normalized data (0-1 range) |
dynamic | Normalize using rescale_min/rescale_max from function | Variable-range data per tile |
none | Skip all normalization, use raw values | Categorical/classified rasters |
Categorical Rasters with rescale_mode = "none"
For classified rasters where pixel values represent discrete categories (land cover, crop types, soil maps), use rescale_mode = "none" with map_type = "discrete":
[[postgres.outdb_rasters]]
id = "landcover"
function = "get_landcover_paths"
[postgres.outdb_rasters.colormap]
map_type = "discrete"
rescale_mode = "none"
nodata_color = "#00000000"
entries = [
{ value = 0, color = "#00000000" }, # nodata
{ value = 1, color = "#FD080C" }, # cotton
{ value = 2, color = "#1D90FF" }, # maize
{ value = 3, color = "#22FDD5" }, # wheat
{ value = 4, color = "#FAD200" }, # sugarcane
{ value = 5, color = "#8B4513" }, # fallow
]
This approach:
- Uses raw pixel values directly (1, 2, 3...) instead of normalized values (0.125, 0.25...)
- Matches values within ±0.5 tolerance for discrete lookup
- Falls back to
nodata_colorwhen no match is found
rescale_mode = "none", you would need to calculate normalized values (e.g., 1/8=0.125 for an 8-class raster) and create step gradients in your colormap entries.References
Next Steps
- Raster Tile Sources - Static COG/VRT configuration
- Vector Tiles - Serve vector tiles from PMTiles/MBTiles
- Configuration Reference - All config options