How to Serve Raster Tiles from a COG with TiTiler
Problem statement
A GeoTIFF needs to appear on a web map. The traditional route is to cut a tile pyramid โ hours of processing, gigabytes of small files, and a rerun every time the raster changes.
A Cloud Optimised GeoTIFF plus a dynamic tile server removes both. The COG is internally tiled and carries overviews, so a tile server can read exactly the bytes one tile needs over a range request and render it on demand. TiTiler is that server: a FastAPI application built on rasterio and rio-tiler that turns any COG URL into an XYZ tile endpoint.
The parts that go wrong are predictable: the source is not actually a valid COG, the rescaling is wrong so every tile is black or white, nothing is cached, and the server reads from slow storage on every request.
Quick answer
pip install "titiler.application"
uvicorn titiler.application.main:app --port 8000
http://localhost:8000/cog/tiles/WebMercatorQuad/{z}/{x}/{y}.png
?url=https://example.org/scene.tif
&rescale=0,3000
&colormap_name=viridis
and in MapLibre:
map.addSource("dem", {
type: "raster",
tiles: ["http://localhost:8000/cog/tiles/WebMercatorQuad/{z}/{x}/{y}.png" +
"?url=" + encodeURIComponent(COG_URL) + "&rescale=0,3000"],
tileSize: 256,
});
The two parameters that decide whether anything appears are url and rescale. Everything else is styling.
Step-by-step solution
1. Make sure the source is a real COG
"Cloud optimised" is a specific internal layout: the image is tiled rather than striped, overviews are present, and the metadata sits at the front of the file. A plain GeoTIFF works and is slow, because reading one tile means reading whole strips.
from rio_cogeo.cogeo import cog_validate
valid, errors, warnings = cog_validate("scene.tif")
print("valid COG:", valid)
for message in errors + warnings:
print(" ", message)
Converting is one command:
from rio_cogeo.cogeo import cog_translate
from rio_cogeo.profiles import cog_profiles
cog_translate("scene.tif", "scene_cog.tif", cog_profiles.get("deflate"),
overview_level=6, web_optimized=True)
web_optimized=True aligns the internal tiling with the Web Mercator grid, so a map tile maps onto whole internal blocks rather than straddling them.
2. Inspect the raster before styling it
Half the "all my tiles are black" reports are a rescale problem, and the fix starts with knowing the data's actual range:
GET /cog/info?url=โฆ bands, dtype, nodata, overviews, bounds
GET /cog/statistics?url=โฆ min, max, percentiles per band
Use the 2nd and 98th percentiles rather than the true minimum and maximum: a single hot pixel otherwise compresses the whole visible range.
3. Set rescale explicitly
TiTiler maps the source values onto 0โ255 for display. For anything that is not already 8-bit โ a DEM in metres, Sentinel-2 reflectance in scaled integers, a float index โ the default is almost certainly wrong.
&rescale=0,3000 one range for all bands
&rescale=0,3000&rescale=0,3000&rescale=0,3000 per band, in order
Everything black usually means the range is far too wide; everything white means it is far too narrow.
4. Choose bands and a colormap
&bidx=4&bidx=3&bidx=2 false colour from a multiband scene
&expression=(b8-b4)/(b8%2Bb4) NDVI, computed per tile
&colormap_name=viridis&rescale=-1,1
expression is evaluated per tile on the fly, which is what makes a dynamic tiler genuinely different from a pyramid: an index can be recomputed with a different formula without regenerating anything. Note that + must be URL-encoded as %2B.
5. Cache, because tile generation is not free
Every tile is a range read plus a decode plus a resample plus an encode. It is fast โ tens of milliseconds from a local COG โ and it is not free, and identical tiles are requested repeatedly.
The measured argument, from an HTTP service: a conditional request answered with 304 ran at 1,191 requests per second and 6.2 ms, against 100 requests per second and 94.6 ms for producing the body. Put a CDN or a caching proxy in front, and set Cache-Control on the responses.
6. Mind where the COG actually lives
A tile served from a COG on the same machine is a local read. The same COG on object storage in another region is a range request per tile, with that latency on every uncached tile.
Both work. The second needs a cache much more urgently, and benefits from GDAL's block cache being sized sensibly:
export GDAL_CACHEMAX=512 # MB
export VSI_CACHE=TRUE
export VSI_CACHE_SIZE=536870912
export GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR
That last one prevents GDAL listing the whole bucket prefix every time it opens a file, which on object storage is the difference between fast and unusable.
Code examples
Example 1 โ a TiTiler application with your own defaults and caching
from fastapi import FastAPI, Request, Response
from titiler.core.factory import TilerFactory
from titiler.core.errors import DEFAULT_STATUS_CODES, add_exception_handlers
app = FastAPI(title="Raster tiles")
cog = TilerFactory()
app.include_router(cog.router, prefix="/cog", tags=["Cloud Optimised GeoTIFF"])
add_exception_handlers(app, DEFAULT_STATUS_CODES)
@app.middleware("http")
async def cache_tiles(request: Request, call_next):
response = await call_next(request)
if "/tiles/" in request.url.path and response.status_code == 200:
response.headers["Cache-Control"] = "public, max-age=86400"
return response
@app.get("/health")
def health():
return {"status": "ok"}
Wrapping the factory rather than running titiler.application directly is what lets you add auth, restrict which URLs may be tiled, and set caching policy โ all of which a public deployment needs.
Example 2 โ restricting which rasters may be served
from fastapi import HTTPException
from urllib.parse import urlparse
ALLOWED_HOSTS = {"data.example.org", "s3.eu-west-2.amazonaws.com"}
ALLOWED_PREFIXES = ("s3://our-bucket/", "/data/cogs/")
def validate_url(url: str) -> str:
"""A tile server that accepts any URL is an open proxy."""
if url.startswith(ALLOWED_PREFIXES):
return url
parsed = urlparse(url)
if parsed.scheme in ("http", "https") and parsed.hostname in ALLOWED_HOSTS:
return url
raise HTTPException(403, f"rasters from {parsed.hostname or url!r} are not served here")
This matters more than it sounds. A ?url= parameter that accepts anything lets a stranger use your server to fetch arbitrary URLs, including internal ones.
Example 3 โ picking the rescale range from the data
import httpx
def suggest_rescale(base_url, cog_url, percentiles=(2, 98)):
"""Ask the server for statistics and produce the rescale parameter."""
stats = httpx.get(f"{base_url}/cog/statistics",
params={"url": cog_url}, timeout=60).json()
ranges = []
for band, values in stats.items():
low = values.get(f"percentile_{percentiles[0]}", values["min"])
high = values.get(f"percentile_{percentiles[1]}", values["max"])
ranges.append((low, high))
print(f"{band}: min {values['min']:.1f} "
f"p{percentiles[0]} {low:.1f} p{percentiles[1]} {high:.1f} "
f"max {values['max']:.1f}")
return "&".join(f"rescale={low:.0f},{high:.0f}" for low, high in ranges)
b1: min -32768.0 p2 12.0 p98 894.0 max 8848.0
suggested: rescale=12,894
The gap between min and p2 in that output is the whole reason to use percentiles: a nodata value of โ32768 that escaped the mask would otherwise flatten the entire display range.
Explanation
Why a COG makes dynamic raster tiling possible
An ordinary GeoTIFF is stored in strips, so reading a small square means reading every strip it touches โ potentially the full width of the image. Internal tiling stores the file in blocks, so a reader can fetch one block.
Overviews add the second half: a zoom 6 tile is rendered from a downsampled overview, not from the full-resolution data. Without overviews, a low-zoom tile reads and downsamples the entire image, which is why a plain GeoTIFF behind a tile server is fast at high zoom and unusable at low zoom.
Why rescale is the parameter that decides whether anything appears
Display is 8-bit; source data usually is not. A DEM in metres runs from โ400 to 8,848; Sentinel-2 reflectance is scaled integers to 10,000; an NDVI is โ1 to 1.
TiTiler cannot know which part of that range is interesting, so it needs telling. Set it from the 2nd and 98th percentiles of the actual data, not from the theoretical range, or one extreme pixel compresses everything else into a few grey levels.
Why GDAL_DISABLE_READDIR_ON_OPEN matters so much on object storage
By default, when GDAL opens a file it lists the containing directory to find sidecar files. On a local disk that is instant. On object storage with a prefix containing thousands of objects, it is a listing request per open โ and a tile server opens the file constantly.
Setting it to EMPTY_DIR tells GDAL not to look. It is the single most impactful environment variable for a cloud-hosted tile server, and it turns "unusably slow" into "fine".
Why dynamic raster tiles and pre-rendered ones are not the same product
A pyramid is fixed: one styling, one band combination, one stretch, decided when it was cut. A dynamic tiler takes the styling as query parameters, so the same COG serves true colour, false colour, an NDVI and three different stretches without any regeneration.
That flexibility is the reason to accept the per-request cost. If only one fixed rendering is ever needed, cutting a pyramid once is cheaper to operate โ and the decision should be made on that basis rather than on which is more modern.
Edge cases or notes
- Validate the COG. A plain GeoTIFF works and is slow, especially at low zoom.
web_optimized=Truewhen converting aligns internal blocks with the tile grid.- Percentiles, not min/max, for the rescale range.
+in anexpressionmust be%2B.- Restrict the
urlparameter, or the service is an open proxy. - Set
GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIRfor object storage. GDAL_CACHEMAXis per process, so it multiplies by the worker count.- Nodata must be declared in the file, or it renders as data.
Internal links
- Cloud optimised GeoTIFF explained โ why the format matters here
- How to write a cloud optimised GeoTIFF โ converting the source
- How to serve raster tiles from a COG โ the tiling pipeline
- Dynamic tile server or static tiles: what to serve โ the underlying decision
- Fixing a COG read that is slow โ the storage-side problems
- How to set cache headers on a spatial API and tile service โ the caching layer
- Fixing a tile endpoint that returns 404 or blank tiles โ when nothing appears
- Fixing rasterio output that is black or empty โ the same rescale problem offline
FAQ
What is TiTiler?
A FastAPI application built on rasterio and rio-tiler that serves XYZ tiles rendered on demand from a Cloud Optimised GeoTIFF, with the band selection, stretch and colormap as query parameters.
Why are all my tiles black?
Almost always the rescale range. Ask the server for /cog/statistics and set it from the 2nd and 98th percentiles rather than the full data range.
Does the source have to be a COG?
No, but it should be. A plain GeoTIFF has no internal tiling and no overviews, so a low-zoom tile reads and downsamples the whole image.
Can I compute an index on the fly?
Yes โ expression=(b8-b4)/(b8%2Bb4) computes NDVI per tile. That flexibility is the main reason to serve dynamically rather than cutting a pyramid.
Is it fast enough for a public map?
With a cache in front, yes. Measured on an HTTP service, a cached conditional response ran at 1,191 requests per second against 100 for generating the body.
What should I set for object storage?
GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR first โ it stops GDAL listing the bucket prefix on every open โ then GDAL_CACHEMAX and the VSI cache settings.