Documentation

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 used to warp each tile. Default: nearest with a discrete colormap, else bilinear
colormapNoColormap for single-band rasters
dataset_cache_sizeNoMax open raster files kept by this source (default: 100, 0 turns the cache off)
dataset_cache_ttl_secsNoSeconds an open raster file stays cached (default: 600)

Resampling

resampling sets the GDAL warp algorithm for every tile: nearest, bilinear, cubic, cubicspline, lanczos, average or mode.

For categorical rasters (land cover, crop classes) use nearest or mode. bilinear mixes the values of two classes at their border and creates class values that are not in the source. With a discrete colormap and no resampling set, the source uses nearest.

Warning

In v2.39.0 and earlier the setting had no effect: every tile was warped with bilinear. Categorical rasters with a discrete colormap now render without the in-between colours at class borders.

Rebuilt raster files

The source keeps recently used raster files open. On each tile it checks the file's modified time and size, and opens the file again when either changed. A VRT or GeoTIFF rebuilt at the same path is served on the next tile, with no restart.

For a VRT, only the VRT file itself is checked. If you change a source file that the VRT references but not the VRT, the change shows up after dataset_cache_ttl_secs. Remote paths (/vsicurl/, /vsis3/) also use the TTL only.

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
Info

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