How to download SRTM DEMs for Python hydrology workflows

Querying the OpenTopography Global DEM API with requests, validating the binary payload with rasterio, and caching results in a project-local directory is the fastest route to a reproducible SRTM tile in a Python hydrology pipeline. This page is part of the SRTM and LiDAR Data Acquisition cluster within the Hydrology Data Preparation & DEM Processing domain. The approach here complements the earthaccess-based NASA Earthdata workflow described in the parent cluster; it is better suited to quick bounding-box queries that do not need a separate NASA account.

The diagram below shows how a single function call moves through authentication, caching, download, and validation before returning a verified GeoTIFF path:

SRTM Download Workflow Five boxes connected left-to-right by arrows: Bbox + API Key, Cache Check (with a Yes/No branch), API Request with retry, Write GeoTIFF, Rasterio Validate, then Return Path. Bbox + Key WGS84 · env var Cache Check hit → return path miss API Request backoff on 429 Write GeoTIFF dem_cache/ Rasterio Validate shape · CRS · bands cache hit → return cached path Path

Prerequisites

This page assumes the base environment from the SRTM and LiDAR Data Acquisition cluster (rasterio>=1.3, requests>=2.31, Python 3.9+). Beyond that baseline, you need:

  • OpenTopography API key — free registration at portal.opentopography.org; inject via an environment variable, never hardcoded.
  • GDAL >= 3.4 linked to your rasterio build (conda install -c conda-forge gdal rasterio on Windows to avoid DLL issues).
  • Write-accessible local directory for the tile cache; at least 500 MB free per large-watershed request.
  • No earthaccess or NASA Earthdata account is required for this approach.

Core technique: bounding-box query against the OpenTopography Global DEM endpoint

The OpenTopography Global DEM API accepts a spatial bounding box, a DEM product identifier (SRTMGL1 for 30 m or SRTMGL3 for 90 m), and an API key, then streams a GeoTIFF in WGS84 (EPSG:4326) directly to the caller. The entire tile is returned as a single binary response — there is no pagination or tile-splitting on the client side for requests under roughly 10,000 km².

The key design decisions in a production wrapper are:

  1. Filename-based caching keyed on bbox coordinates and resolution string so that repeated pipeline runs skip the network entirely.
  2. Exponential-backoff retry on HTTP 429 responses — the API enforces fair-use limits and a bare response.raise_for_status() loop will crash long overnight jobs.
  3. Immediate structural validation with rasterio after every write — a corrupted or empty GeoTIFF will silently pass through downstream DEM pit filling algorithms and produce nonsensical flow direction grids if not caught here.

Annotated code example

python
import logging
import os
import time
from pathlib import Path

import rasterio
import requests

logger = logging.getLogger(__name__)


def download_srtm_dem(
    bbox: tuple[float, float, float, float],
    output_dir: str = "dem_cache",
    resolution: str = "SRTMGL1",
    api_key: str | None = None,
    max_retries: int = 3,
) -> Path:
    """Download an SRTM tile for *bbox* from the OpenTopography Global DEM API.

    Parameters
    ----------
    bbox:
        ``(south, north, west, east)`` in decimal degrees, WGS84 (EPSG:4326).
        Verify against your watershed CRS before calling — inverted coordinates
        return a valid HTTP 200 with an empty GeoTIFF.
    resolution:
        ``'SRTMGL1'`` for 30 m (~1 arc-second) or ``'SRTMGL3'`` for 90 m.
    api_key:
        OpenTopography API key.  Required for every Global DEM request since
        mid-2023.  Pass via ``os.environ['OPENTOPOGRAPHY_API_KEY']`` in production.
    max_retries:
        Number of retry attempts on HTTP 429 (rate-limit) before raising.
    """
    if not api_key:
        raise ValueError(
            "OpenTopography requires an API key for all Global DEM requests. "
            "Register at https://portal.opentopography.org/ and store the key "
            "in the OPENTOPOGRAPHY_API_KEY environment variable."
        )

    cache_dir = Path(output_dir)
    cache_dir.mkdir(parents=True, exist_ok=True)

    south, north, west, east = bbox

    # Cache filename encodes all request parameters so hits are deterministic.
    out_path = cache_dir / f"srtm_{resolution}_{west}_{east}_{south}_{north}.tif"

    if out_path.exists():
        logger.info("Cache hit — skipping download: %s", out_path)
        return out_path

    url = "https://portal.opentopography.org/API/globaldem"
    params = {
        "demtype": resolution,
        "south": south,
        "north": north,
        "west": west,
        "east": east,
        "outputFormat": "GTiff",
        "API_Key": api_key,        # required since mid-2023
    }

    logger.info(
        "Requesting %s tile for bbox=(%s, %s, %s, %s)",
        resolution, south, north, west, east,
    )

    response = None
    for attempt in range(max_retries):
        response = requests.get(url, params=params, timeout=120)
        if response.status_code == 429:
            # Exponential backoff: 5 s, 10 s, 20 s …
            wait_seconds = (2 ** attempt) * 5
            logger.warning(
                "Rate limited (attempt %d/%d). Retrying in %ds …",
                attempt + 1, max_retries, wait_seconds,
            )
            time.sleep(wait_seconds)
            continue
        response.raise_for_status()
        break
    else:
        raise RuntimeError(
            f"OpenTopography rate limit persisted after {max_retries} attempts. "
            "Reduce request frequency or split the bbox into smaller tiles."
        )

    # Write raw bytes — do not decode; GeoTIFF is a binary format.
    out_path.write_bytes(response.content)
    logger.info("Wrote %d bytes to %s", len(response.content), out_path)

    # Validate structural integrity before returning.  A 200 OK response can
    # still contain an HTML error page or an empty raster if the bbox is outside
    # SRTM coverage or the key lacks the required permission tier.
    with rasterio.open(out_path) as src:
        if src.count == 0 or src.width == 0 or src.height == 0:
            out_path.unlink()          # remove corrupt file from cache
            raise ValueError(
                f"Downloaded file is empty or not a valid GeoTIFF: {out_path}. "
                "Check that the bbox intersects SRTM coverage and the API key is active."
            )
        logger.info(
            "Validated %s | shape=%s | CRS=%s | nodata=%s",
            out_path.name, src.shape, src.crs, src.nodata,
        )

    return out_path


# ---------------------------------------------------------------------------
# Usage example — Southern California watershed, 30 m SRTM
# ---------------------------------------------------------------------------
if __name__ == "__main__":
    logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")

    dem_path = download_srtm_dem(
        bbox=(34.5, 35.2, -118.5, -117.8),   # (south, north, west, east)
        output_dir="data/raw/srtm",
        resolution="SRTMGL1",
        api_key=os.environ["OPENTOPOGRAPHY_API_KEY"],
    )
    print(f"DEM ready: {dem_path}")

Parameter reference

Parameter Accepted values Effect on hydrology
resolution SRTMGL1 (30 m), SRTMGL3 (90 m) 30 m is standard for basin-scale flow accumulation; 90 m smooths channel networks and underestimates drainage density
outputFormat GTiff (only option via this endpoint) Returns a single-band int16 GeoTIFF in EPSG:4326 with EGM96 vertical datum
API_Key 32-character alphanumeric string Required; absent key returns HTTP 401; invalid key returns 403
timeout (requests) Integer seconds; 120 recommended Large bbox tiles can exceed 400 MB and take 60+ s on slow connections
max_retries 2–5; default 3 Higher values tolerate shared-API environments but increase total wall time

Worked example: output interpretation

A successful download and validation log looks like this:

text
INFO: Requesting SRTMGL1 tile for bbox=(34.5, 35.2, -118.5, -117.8)
INFO: Wrote 47382110 bytes to data/raw/srtm/srtm_SRTMGL1_-118.5_-117.8_34.5_35.2.tif
INFO: Validated srtm_SRTMGL1_-118.5_-117.8_34.5_35.2.tif | shape=(2520, 2520) | CRS=EPSG:4326 | nodata=-32768

Key values to verify:

  • shape: For a 0.7° × 0.7° bbox at SRTMGL1 resolution, expect roughly 2,520 × 2,520 pixels. Shapes much smaller than expected indicate the bbox clipped the edge of SRTM coverage.
  • CRS = EPSG:4326: Raw SRTM is delivered in geographic coordinates (degrees), not meters. Before running flow routing algorithms or computing slope, reproject to a metric CRS with rasterio.warp.reproject.
  • nodata = -32768: SRTM void pixels carry this sentinel value. They appear as gaps over water bodies, radar-shadow zones in steep terrain, and locations outside the 56°S–60°N acquisition strip. Pass the file to a DEM pit filling algorithm after reprojection to close these voids before deriving flow direction.
  • Anomaly — shape (0, 0): The raster is empty. Most common cause is a bbox that falls entirely outside SRTM coverage (polar regions, open ocean) or a malformed coordinate order.
  • Anomaly — file size < 10 KB: The API returned an HTML error page rather than a GeoTIFF. Check the response.content before writing, or inspect the file with a text editor.

Gotchas and edge cases

  • Coordinate order is (south, north, west, east), not (west, south, east, north). The OpenTopography API uses a non-standard lat/lon-first order. Passing a Shapely or GeoPandas bounds tuple (which is minx, miny, maxx, maxy) without reordering silently produces a valid-looking request that returns a tile for the wrong geographic extent.

  • SRTM voids are int16 -32768, not NaN. If you apply a mask or nodata comparison using floating-point NaN before reprojecting, the void pixels will slip through and corrupt flow accumulation. Always use src.nodata from the rasterio dataset metadata when masking.

  • Large bounding boxes trigger server-side chunking. Requests covering more than roughly 10,000 km² may silently return a partial tile or time out before the full payload arrives. Split into ≤ 5° × 5° sub-tiles, download each independently, and merge with rasterio.merge or rasterio.windows before conditioning.

  • The API_Key parameter name is case-sensitive. The API rejects api_key or apikey with a 401 even when the key itself is valid. Use API_Key exactly as shown.

  • Re-projecting before pit filling can introduce new voids. Bilinear or cubic resampling interpolates across nodata boundaries and may deposit fractional values at void edges. Reproject with resampling=Resampling.nearest on the initial pass, apply DEM pit filling, then optionally re-smooth with a higher-quality resampling kernel if slope accuracy is critical.