Guides

PostgreSQL Out-of-Database Rasters

Serve VRT/COG raster tiles via PostGIS functions with dynamic filtering

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?

ApproachStoragePerformanceUse Case
In-DB RasterPixels in PostgreSQLHeavy DB loadAnalysis, small datasets
Out-DB RasterMetadata + file pathsFast, GDAL rendersTile 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 postgres and raster features

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

OptionRequiredDescription
idYesUnique source identifier (also used as function name if function omitted)
schemaNoPostgreSQL schema (default: public)
functionNoFunction name that returns file paths (default: id value)
nameNoDisplay name
minzoomNoMinimum zoom level (default: 0)
maxzoomNoMaximum zoom level (default: 22)
boundsNoBounding box west, south, east, north
resamplingNoResampling method (default: bilinear)
colormapNoColormap 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

ParameterTypeDescription
zINTZoom level
xINTTile X coordinate
yINTTile Y coordinate
tile_bboxGEOMETRYWeb Mercator (EPSG:3857) tile bounding box
paramsJSONBAll 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

OptionValuesDescription
map_typediscrete, continuousDiscrete for exact matching, continuous for interpolation
rescale_modestatic, dynamic, noneHow to transform pixel values before colormap lookup
nodata_colorHex colorColor for nodata pixels (e.g., #00000000 for transparent)
entriesArrayValue-to-color mappings

Rescale Modes

ModeDescriptionUse Case
staticUse raw pixel values directly (default)Pre-normalized data (0-1 range)
dynamicNormalize using rescale_min/rescale_max from functionVariable-range data per tile
noneSkip all normalization, use raw valuesCategorical/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_color when no match is found
Without 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