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 withReadError: 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
.tararchives into memory withresp.contentwill 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.
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_idprevents hardcoded IDs from breaking silently. Product IDs such as5e83d0b84df8d8c2are internal USGS identifiers that change between API releases and dataset editions. Callingdownload-optionsat runtime and selecting byproductNamestring-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 inget_download_urlshandles the case where large batches are not yet ready whendownload-retrieveis first called. -
stream=Truewithiter_content()is the only safe pattern for archives over ~100 MB. Without streaming,requestsbuffers 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. A10second connect timeout catches DNS or firewall issues fast; a300second 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 > 0or compare againstContent-Lengthto catch truncated prior downloads. -
MD5 validation removes corrupt archives before they silently propagate. Corrupt
.tarfiles passed totarfile.extractall()raiseReadErrordeep 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.
Related
- Bulk Downloading Satellite Imagery β parent guide covering alternative protocols (S3, STAC, OGC API) and storage layout strategies
- Syncing STAC Catalogs with pystac-client β STAC-native approach for Landsat, Sentinel-2, and commercial imagery without token management
- Automating Government Portal Downloads β similar pattern for non-API portals requiring session-cookie authentication
- Aligning Raster Bands with rasterio and Affine Transforms β next step after extraction: reprojecting and aligning downloaded scenes for multi-date compositing