This page is part of the Bulk Downloading Satellite Imagery guide, which is itself part of Mastering Geospatial Data Ingestion in Python.

Automating USGS EarthExplorer Bulk Downloads with requests

Problem definition

The USGS EarthExplorer web interface blocks all HTTP automation with anti-bot protections and CSRF tokens. The only compliant path to programmatic bulk download is the USGS Machine-to-Machine (M2M) API v1.5, which issues time-limited signed URLs that requests can consume without triggering rate-limit bans or terms-of-service violations.

Why this failure breaks spatial pipelines

  • Silent archive corruption. Scraping the web UI mid-session returns partial HTML fragments rather than raising an HTTP error, so downstream tarfile.open() calls fail with ReadError: not a gzip file β€” a confusing error that appears to be a storage problem, not a download problem.
  • Credential leakage risk. Hardcoding USGS credentials in pipeline code or embedding them in URL query strings exposes them in server logs; the M2M token pattern keeps credentials out of URLs entirely.
  • Unbounded memory growth. Loading multi-gigabyte .tar archives into memory with resp.content will exhaust available RAM on standard ingest workers; chunked streaming is mandatory.
  • Cascading job failures. Without exponential backoff on HTTP 429, a single throttled request causes the entire batch to fail; retry logic at the session level makes retries transparent to calling code.

Version and environment compatibility

requests urllib3 Python Notes
2.31+ 2.0+ 3.10+ Retry(allowed_methods=...) replaces deprecated method_whitelist
2.28–2.30 1.26.x 3.9+ Use method_whitelist=["GET","POST"] in Retry(); allowed_methods not available
2.25–2.27 1.26.x 3.8+ Works but backoff_factor caps at 120 s by default; set backoff_max explicitly if needed
< 2.25 < 1.26 3.7 Avoid: missing pool_block support; upgrade before building production pipelines

M2M API three-phase pipeline

The EarthExplorer M2M workflow is not a single request β€” it is a three-phase protocol. Each phase must complete before the next can start.

Three-phase USGS M2M Download Pipeline Sequence diagram showing the three phases of the USGS M2M API: Phase 1 Token Acquisition via POST /login, Phase 2 Scene Metadata Resolution via POST /scene-search, and Phase 3 Chunked File Streaming via download-request, download-retrieve, and GET signed URL. Phase 1 Token Acquisition Phase 2 Scene Metadata Resolution Phase 3 Chunked File Streaming POST /login β†’ X-Auth-Token (~2 h) Session + Retry adapter pool_connections=10 POST /scene-search MBR + date filter β†’ entityIds POST /download-options enumerate productIds POST /download-request β†’ /download-retrieve GET signed URL, stream=True iter_content(8192) β†’ disk

Production-ready download pipeline

The function below covers all three phases end-to-end. It is designed as a single copy-pasteable module for integration into an ETL orchestration task.

import os
import json
import time
import hashlib
import logging
from pathlib import Path
from urllib.parse import urljoin
from requests import Session
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger(__name__)

USGS_M2M_BASE = "https://m2m.cr.usgs.gov/api/api/json/v1.5/"
DATASET_NAME  = "landsat_ot_c2_l1"   # Landsat 8/9 Collection 2 Level-1
OUTPUT_DIR    = Path("./usgs_downloads")
OUTPUT_DIR.mkdir(exist_ok=True)


# ── Phase 1: Session + Token ──────────────────────────────────────────────────

def get_session() -> Session:
    """Return a requests.Session with connection pooling and exponential backoff.

    backoff_factor=1.5 produces delays of ~1.5 s, 3 s, 6 s, 12 s, 18 s before
    each successive retry β€” enough to ride out USGS 429 bursts without hammering
    the endpoint. pool_connections/pool_maxsize=10 allows concurrent workers to
    share an underlying urllib3 connection pool instead of opening a fresh TLS
    handshake per request.
    """
    session = Session()
    retry_strategy = Retry(
        total=5,
        backoff_factor=1.5,
        status_forcelist=[429, 500, 502, 503, 504],
        allowed_methods=["GET", "POST"],   # urllib3 >= 2.0; use method_whitelist on older
    )
    adapter = HTTPAdapter(
        max_retries=retry_strategy,
        pool_connections=10,
        pool_maxsize=10,
    )
    session.mount("https://", adapter)
    session.headers.update({"Content-Type": "application/json"})
    return session


def authenticate(session: Session, username: str, password: str) -> str:
    """Exchange USGS credentials for a time-limited X-Auth-Token.

    Tokens expire after ~120 minutes. For pipelines that run longer, catch
    HTTP 401 inside the download loop and call this function again to refresh.
    Credentials must come from environment variables or a secrets manager β€”
    never from source code or CLI arguments that appear in process lists.
    """
    payload = {"username": username, "password": password}
    resp = session.post(urljoin(USGS_M2M_BASE, "login"), json=payload, timeout=30)
    resp.raise_for_status()
    token = resp.json()["data"]
    logger.info("M2M authentication successful. Token valid for ~2 hours.")
    return token


# ── Phase 2: Scene Metadata Resolution ────────────────────────────────────────

def search_scenes(
    session: Session,
    token: str,
    bbox: list[float],
    start_date: str,
    end_date: str,
    max_results: int = 50,
) -> list[dict]:
    """Return scene metadata records matching a bounding box and date range.

    bbox must be [min_lon, min_lat, max_lon, max_lat] in WGS-84 decimal degrees.
    The M2M spatialFilter uses lowerLeft/upperRight dicts β€” not GeoJSON order.
    """
    lower_left  = {"latitude": bbox[1], "longitude": bbox[0]}
    upper_right = {"latitude": bbox[3], "longitude": bbox[2]}

    payload = {
        "datasetName": DATASET_NAME,
        "maxResults":  max_results,
        "sceneFilter": {
            "acquisitionFilter": {"start": start_date, "end": end_date},
            "spatialFilter": {
                "filterType":  "mbr",
                "lowerLeft":   lower_left,
                "upperRight":  upper_right,
            },
        },
    }
    headers = {"X-Auth-Token": token}
    resp = session.post(
        urljoin(USGS_M2M_BASE, "scene-search"),
        json=payload,
        headers=headers,
        timeout=60,
    )
    resp.raise_for_status()
    results = resp.json()["data"]["results"]
    logger.info("scene-search returned %d scenes.", len(results))
    return results


def resolve_product_id(
    session: Session,
    token: str,
    entity_id: str,
    preferred_format: str = "Bundle",
) -> str | None:
    """Enumerate available download products and return the first matching productId.

    Never hardcode productIds β€” they change across API versions and dataset
    releases. This helper calls download-options for a single representative
    entityId and selects the preferred bundle type.
    """
    headers = {"X-Auth-Token": token}
    payload  = {"datasetName": DATASET_NAME, "entityIds": [entity_id]}
    resp = session.post(
        urljoin(USGS_M2M_BASE, "download-options"),
        json=payload,
        headers=headers,
        timeout=30,
    )
    resp.raise_for_status()
    for option in resp.json().get("data", []):
        if preferred_format.lower() in option.get("productName", "").lower():
            return option["id"]
    return None


def get_download_urls(
    session: Session,
    token: str,
    entity_ids: list[str],
    product_id: str,
) -> dict[str, str]:
    """Resolve temporary signed download URLs via the two-step M2M protocol.

    Step 1 (download-request) enqueues the archives for preparation on USGS
    servers. Step 2 (download-retrieve) polls for readiness and returns signed
    S3 URLs. URLs are short-lived (~15 minutes); start streaming immediately.
    """
    headers = {"X-Auth-Token": token}

    # Step 1: enqueue archive preparation
    products_payload = {
        "downloads": [
            {"datasetName": DATASET_NAME, "entityId": eid, "productId": product_id}
            for eid in entity_ids
        ],
        "label": "etl-bulk-download",
    }
    resp = session.post(
        urljoin(USGS_M2M_BASE, "download-request"),
        json=products_payload,
        headers=headers,
        timeout=60,
    )
    resp.raise_for_status()

    # Step 2: retrieve signed URLs (may need a short wait for large batches)
    retrieve_payload = {"label": "etl-bulk-download"}
    for attempt in range(5):
        resp = session.post(
            urljoin(USGS_M2M_BASE, "download-retrieve"),
            json=retrieve_payload,
            headers=headers,
            timeout=60,
        )
        resp.raise_for_status()
        available = resp.json()["data"].get("available", [])
        if available:
            break
        logger.info("download-retrieve: no URLs ready yet, waiting 10 s (attempt %d/5).", attempt + 1)
        time.sleep(10)

    url_map: dict[str, str] = {}
    for item in available:
        url_map[item["entityId"]] = item["url"]
    logger.info("Resolved %d download URLs.", len(url_map))
    return url_map


# ── Phase 3: Chunked File Streaming ───────────────────────────────────────────

def _md5_file(path: Path) -> str:
    h = hashlib.md5()
    with path.open("rb") as f:
        for chunk in iter(lambda: f.read(65536), b""):
            h.update(chunk)
    return h.hexdigest()


def download_file(
    session: Session,
    url: str,
    dest_path: Path,
    chunk_size: int = 8192,
    expected_md5: str | None = None,
) -> None:
    """Stream a large archive to disk without loading it into memory.

    The Content-Length header is used only for progress logging; its absence
    does not prevent the download. After writing, optionally verifies MD5
    against a checksum supplied by the M2M API response metadata.
    """
    with session.get(url, stream=True, timeout=(10, 300)) as resp:
        resp.raise_for_status()
        total_bytes = int(resp.headers.get("content-length", 0))
        downloaded  = 0

        with open(dest_path, "wb") as f:
            for chunk in resp.iter_content(chunk_size=chunk_size):
                if chunk:   # filter out keep-alive empty chunks
                    f.write(chunk)
                    downloaded += len(chunk)
                    if total_bytes:
                        pct = downloaded / total_bytes * 100
                        logger.info(
                            "%s: %.1f MB / %.1f MB (%.0f%%)",
                            dest_path.name,
                            downloaded / 1_048_576,
                            total_bytes / 1_048_576,
                            pct,
                        )

    if expected_md5 and _md5_file(dest_path) != expected_md5:
        dest_path.unlink()
        raise ValueError(f"MD5 mismatch for {dest_path.name} β€” file removed, retry required.")

    logger.info("Download complete: %s", dest_path)


# ── Orchestrating run ──────────────────────────────────────────────────────────

def run_pipeline(
    username: str,
    password: str,
    bbox: list[float],
    start_date: str,
    end_date: str,
) -> None:
    """Full three-phase M2M download pipeline with idempotent file checks."""
    session = get_session()
    token   = authenticate(session, username, password)

    scenes = search_scenes(session, token, bbox, start_date, end_date)
    if not scenes:
        logger.warning("No scenes found for the specified criteria.")
        return

    entity_ids = [s["entityId"] for s in scenes]

    # Discover product ID dynamically from a representative entity
    product_id = resolve_product_id(session, token, entity_ids[0])
    if not product_id:
        raise RuntimeError("No downloadable bundle found for dataset '%s'.", DATASET_NAME)

    url_map = get_download_urls(session, token, entity_ids, product_id)

    for entity_id, dl_url in url_map.items():
        dest = OUTPUT_DIR / f"{entity_id}.tar"
        if dest.exists():
            logger.info("Skipping already downloaded file: %s", dest.name)
            continue
        try:
            download_file(session, dl_url, dest)
        except Exception as exc:
            logger.error("Failed to download %s: %s", entity_id, exc)


if __name__ == "__main__":
    run_pipeline(
        username=os.environ["USGS_USERNAME"],
        password=os.environ["USGS_PASSWORD"],
        bbox=[-122.5, 37.7, -122.3, 37.9],   # San Francisco Bay Area
        start_date="2023-06-01",
        end_date="2023-06-30",
    )

Key implementation notes

  • resolve_product_id prevents hardcoded IDs from breaking silently. Product IDs such as 5e83d0b84df8d8c2 are internal USGS identifiers that change between API releases and dataset editions. Calling download-options at runtime and selecting by productName string-match is far more robust than embedding an ID.

  • Two-step URL resolution is mandatory, not optional. The M2M API does not return signed URLs immediately from download-request. It enqueues work on USGS servers. The polling loop in get_download_urls handles the case where large batches are not yet ready when download-retrieve is first called.

  • stream=True with iter_content() is the only safe pattern for archives over ~100 MB. Without streaming, requests buffers the entire response body in RAM before returning. A 2 GB Landsat scene will exhaust most ingest worker memory limits before a single byte reaches disk.

  • Timeout tuple (connect_timeout, read_timeout) separates two failure modes. A 10 second connect timeout catches DNS or firewall issues fast; a 300 second read timeout allows the USGS CDN to serve the first byte of large archives without the session being torn down prematurely.

  • Idempotent file-existence check makes the pipeline safe to re-run. If a batch job is interrupted partway through, re-running it skips already-completed files and resumes from the point of failure. Pair this with partial-file detection: check dest.stat().st_size > 0 or compare against Content-Length to catch truncated prior downloads.

  • MD5 validation removes corrupt archives before they silently propagate. Corrupt .tar files passed to tarfile.extractall() raise ReadError deep in subsequent pipeline stages, often masking the true cause as a storage or processing problem rather than a download failure. Validating at download time and removing the bad file forces an immediate, actionable error.

Troubleshooting reference

Symptom Root cause Fix
401 Unauthorized on scene-search Token expired after 120 min Catch 401, call authenticate() again, retry the request
download-retrieve returns empty available list after 5 polls Large batch still queued on USGS servers Increase poll sleep to 30 s and retry up to 10 times
tarfile.ReadError: not a gzip file on extraction Partial download stored without size check Delete file, re-run; add st_size guard before skipping
ConnectionError: HTTPSConnectionPool USGS CDN unreachable or TLS cert issue Verify certifi is current (pip install -U certifi)
KeyError: 'data' on API response Wrong datasetName or dataset not in your M2M subscription Call dataset-search to enumerate permitted dataset names

Integration note

This recipe slots directly into the broader workflow described in Bulk Downloading Satellite Imagery as the extraction step that precedes raster transformation. Once archives are on disk, pass them to rasterio for band stacking and projection, applying CRS normalization across mixed datasets before any spatial join or analysis. For STAC-based alternatives that avoid the M2M token refresh cycle entirely, see the guide on syncing STAC catalogs with pystac-client β€” both approaches can coexist in a pipeline that sources imagery from multiple government platforms.