Planet Bulk Download
Hard-won reference for pulling PlanetScope imagery at scale with the Planet SDK v2. The SDK is thin: no retries, no batching, no awareness of cold-storage thaw, no scene grouping. You build all of that. This document is the part you will otherwise rediscover by burning a 17-hour batch job.
Architecture: three-phase pipeline
Split the job into search → activate → extract, each with its own JSONL cache, its own concurrency knob, and its own resume logic. The phases have completely different bottlenecks:
- search: cheap metadata calls, rate-limited client-side.
- activate: Planet-side cold-storage thaw queue, capped at ~10 cold scenes/min/account regardless of how many you request.
- extract: HTTP range reads via GDAL, network-bound at ~0.5s/patch on a 1.5km AOI.
Suggested layout:
_global/
manifest.jsonl # input AOIs, one per line
search/shard_NNN.jsonl # per-AOI search results
activations.jsonl # item_id -> signed URL, append-only
extract/shard_NNN.jsonl # per-patch extract status
Phase a re-activation pass after the first extract pass (see Reliability Gotchas below).
Scene grouping is the activation budget killer
Many patches share scenes (mean ~23 patches/scene in practice). Group all patches needing scene S, activate S once, then read every patch's window from the warm URL. Cuts activations 10-20x.
from collections import defaultdict
scene_to_patches = defaultdict(list)
for patch in patches:
scene_to_patches[patch.item_id].append(patch)
# Activate keys once; extract reads many windows per key.
Sharded array jobs
For search and extract, shard by patch index. Aim 1-5k entries per shard so a single shard finishes in 10-30 min (good for walltime budgeting and partial-progress visibility).
shard_id = hash(item_id) % num_shards # for extract: keeps same scene in same task
Hash by item_id for extract so the same scene is always processed by the same task — GDAL's /vsicurl/ cache stays warm across patches.
COG range-reads beat the Orders API by ~100x
PSScene ortho_analytic_4b_sr has been a Cloud-Optimized GeoTIFF since ~2022. After :activate, the signed location URL supports HTTP range reads via GDAL /vsicurl/. For windowed access (anything less than a full strip), the Orders API queue + clip-tool path is dramatically slower and adds another queue to fail through.
Use the Orders API only when you need full-strip downloads or server-side reprojection you cannot do locally.
import rasterio
from rasterio.windows import from_bounds
url = activation_row["location"] # signed Planet URL with ?token=
vsicurl_url = f"/vsicurl/{url}"
with rasterio.Env(**GDAL_OPTS):
with rasterio.open(vsicurl_url) as src:
window = from_bounds(*patch_bounds, transform=src.transform)
data = src.read(window=window)
GDAL config for /vsicurl/ COG reads
Apply via rasterio.Env(**opts). Pass numeric values as Python ints, not strings — GDAL_CACHEMAX and a few others go through C funcs that require int. Strings silently break with cryptic errors.
GDAL_OPTS = dict(
GDAL_HTTP_VERSION="2",
GDAL_HTTP_MULTIPLEX="YES",
GDAL_DISABLE_READDIR_ON_OPEN="EMPTY_DIR",
CPL_VSIL_CURL_USE_HEAD="NO",
GDAL_INGESTED_BYTES_AT_OPEN=32768, # int
GDAL_HTTP_MERGE_CONSECUTIVE_RANGES="YES",
GDAL_CACHEMAX=1024, # int, MB
VSI_CACHE="TRUE",
VSI_CACHE_SIZE=67108864, # int
)
Critical: do NOT set CPL_VSIL_CURL_ALLOWED_EXTENSIONS. Planet's signed URLs end with ?token=..., not .tif. The extension allowlist will reject them. Half a day was lost to this.
Retry logic (the SDK has none)
Wrap every Data API call — search, list_item_assets, activate_asset, wait_asset — in a retry that handles all of:
planet.exceptions.TooManyRequests(429). Exponential backoff starting 0.5s, cap 30s, ~8 attempts. Planet does not sendRetry-After; pace yourself.planet.exceptions.APIErrorwhose message contains"503","502","504", or"Server Error". Same backoff. The SDK does not expose a clean status code on all error paths — heuristic match on the message string is required.httpx.ConnectTimeout,httpx.ReadTimeout,httpx.ConnectError,httpx.RemoteProtocolError,httpx.ReadError. All happen on multi-hour runs.
import asyncio, random
import httpx
from planet.exceptions import TooManyRequests, APIError
from typing import Any, cast
TRANSIENT_HTTPX = (
httpx.ConnectTimeout, httpx.ReadTimeout, httpx.ConnectError,
httpx.RemoteProtocolError, httpx.ReadError,
)
async def with_retry(fn, *args, max_attempts=8, base=0.5, cap=30.0, **kw):
for attempt in range(max_attempts):
try:
return await fn(*args, **kw)
except TooManyRequests:
pass
except APIError as e:
msg = str(e)
if not any(s in msg for s in ("503", "502", "504", "Server Error")):
raise
except TRANSIENT_HTTPX:
pass
delay = min(cap, base * (2 ** attempt)) * (0.5 + random.random())
await asyncio.sleep(delay)
raise RuntimeError(f"retry exhausted: {fn.__name__}")
sess.client("data") returns an Any-typed object that breaks static checkers. Cast to Any to silence:
data_client = cast(Any, sess.client("data"))
Concurrency tuning (measured, paid account)
| Phase | Per-node concurrency | Notes |
|---|---|---|
| Search | 16/shard × 8 shards (~128 inflight) | 512 inflight gets 429-throttled. Aim 64-128. |
| Activate | 32-96 | Planet's queue caps total throughput ~10 cold scenes/min/account. Past ~64 is diminishing returns. |
| Extract | 32-64 | Network-bound ~0.5s read + 0.08s write per 1.5km patch. Disk is not the bottleneck. |
The per-account activation cap is undocumented. Ask Planet support directly if you need it lifted.
Activate SR and UDM2 in parallel via asyncio.gather. UDM2 is ~3x slower than SR for unknown reasons (worth reporting to Planet). Doing them serially halves throughput.
sr_task = asyncio.create_task(activate(item_id, "ortho_analytic_4b_sr"))
udm2_task = asyncio.create_task(activate(item_id, "ortho_udm2"))
sr_url, udm2_url = await asyncio.gather(sr_task, udm2_task)
Planet reliability gotchas (plan around these)
- ~24% of
:activateresponses returnstatus=activewith a broken URL. First range-read fails HTTP 400:"Failure writing output to destination, passed 107 returned 0". Solution: a second:activatereturns a different, working URL — asset is still active, the URL itself was corrupted. Build a re-activation pass: scan extract logs foropen_failed, call:activateagain, append a fresh row toactivations.jsonl. Use last-write-wins onitem_idwhen loading. - Some activations return success with
location=null. Same pattern; re-activate. - Pre-2018 archive and certain regions (India, Finland observed) have permanently dead assets. Multiple reactivations do not help. Budget 3-5% unrecoverable.
- Cold-storage thaw p99 ≥ 45 minutes for never-touched scenes. Mean ~3 min once warm. If Planet support enables it, pre-warming a list of scene IDs ahead of time collapses the long tail.
Efficient patterns
Resumable JSONL caches everywhere
Append-only, flushed every line. On rerun, build the set of done keys and skip them. Cheap and crash-safe.
def append_row(path, row):
with open(path, "a") as f:
f.write(json.dumps(row) + "\n")
f.flush()
def load_done(path, key):
done = set()
if not os.path.exists(path):
return done
with open(path) as f:
for line in f:
try:
done.add(json.loads(line)[key])
except Exception:
continue # NFS append-races produce torn lines
return done
Always wrap json.loads in try/except. NFS append-races produce torn lines that crash a naive loader.
Don't wipe output files before re-extract
If you wipe <patch>.tif for resample then the resample fails, you have lost the original too. Either:
- Don't wipe — let
rasterio.open(path, "w")overwrite naturally on success. - Write to
<patch>.tif.tmpandos.replaceon success.
UDM2 (or any small companion asset) gets its own 3-phase pass
Run a separate plan → activate → extract pass for UDM2 after the SR pipeline lands. Ships SR-first results while UDM2 is still thawing on Planet's side.
Patch-level cloud cover ≠ scene-level cloud cover
A scene with 10% scene-level cloud can have a specific 1.5km patch at 100% cover. Always probe UDM2 at the actual patch AOI post-hoc. Re-sample cloudy patches against alternative scenes from the search results.
Anti-patterns
- Do not use the Orders API for windowed access at scale.
- Do not trust the SDK to auto-retry — there are no built-in retries.
- Do not activate UDM2 inside per-patch loops — batch by unique
item_idfirst. - Do not set
CPL_VSIL_CURL_ALLOWED_EXTENSIONSwith Planet signed URLs. - Do not pass GDAL config numeric values as strings.
- Do not run
wait_assetpolling without a max-attempts cap — stuck scenes will pin a worker forever. - Do not wipe existing outputs before a re-extract attempt.
- Do not assume
status=activemeans the URL works — verify with the first read, retry-by-reactivate on failure.
Quick pre-flight checklist
- AOIs deduped and grouped by
item_idbefore any activation call. - JSONL caches for search/activations/extract wired up and resumable.
- Retry wrapper installed around every Data API call (
429+5xxstrings +httpxtransients). - GDAL config dict in place, numeric values as ints,
CPL_VSIL_CURL_ALLOWED_EXTENSIONSunset. - SR and UDM2 activations launched in parallel via
asyncio.gather. - Concurrency capped (search ≤128, activate ≤96, extract ≤64).
- Re-activation pass planned for
open_failedextract rows. - Budget 3-5% permanently unrecoverable assets.
- Shard size 1-5k entries; extract sharded by
hash(item_id). - Post-hoc UDM2 cloud probe at patch AOI, not scene level.