planet-bulk-download
Skill78 starsChanged 8 months agoApache-2.0View on GitHub
Download PlanetScope imagery at scale (hundreds to 100k+ AOIs) via the Planet Python SDK v2. Covers search/activate/extract phases, COG /vsicurl/ vs Orders API, GDAL config for signed URLs, retry logic, concurrency budgets, and Planet-side gotchas (broken activations, cold-storage thaw tails, dead assets). Use when downloading at scale, hitting 429s or stuck activations, choosing Orders API vs COG reads, or designing a resumable batch Planet pipeline.
In this repository19 other files it gives its agents.
- .github/copilot-instructions.mdCopilot instructions
- detect-objectsSkill
- gdalSkill
- geoparquet-validationSkill
- geospatial-frontendSkill
- geospatial-viewersSkill
- geozarrSkill
- overture-dataSkill
- parallel-geospatialSkill
- pmtiles-pipelineSkill
- portolan-bootstrapSkill
- portolan-cliSkill
- portolan-thumbnailsSkill
- reading-portolanSkill
- register-catalogSkill
- search-stacSkill
- sourcecoopSkill
- tesseraSkill
- zarr-multiscalesSkill
The file
Show its rules and text
We split this skill into the rules it gives an agent
13 rules. About 0 are common, 6 are distinctive, and 7 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈6
Distinctive · estimate
This author's own choices. Worth a look.
7
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 56
Use the Orders API only when you need full-strip downloads or server-side reprojection you cannot do locally.
- Very specific: line 186
If you wipe <patch>.tif for resample then the resample fails, you have lost the original too.
- Very specific: line 188
Don't wipe — let rasterio.open(path, "w") overwrite naturally on success.
- Very specific: line 189
Write to <patch>.tif.tmp and os.replace on success.
- Distinctive: line 193
Run a separate plan → activate → extract pass for UDM2 after the SR pipeline lands.
- Distinctive: line 201
Do not use the Orders API for windowed access at scale.
- Distinctive: line 202
Do not trust the SDK to auto-retry — there are no built-in retries.
- Very specific: line 203
Do not activate UDM2 inside per-patch loops — batch by unique item_id first.
- Very specific: line 204
Do not set CPL_VSIL_CURL_ALLOWED_EXTENSIONS with Planet signed URLs.
- Distinctive: line 205
Do not pass GDAL config numeric values as strings.
- Very specific: line 206
Do not run wait_asset polling without a max-attempts cap — stuck scenes will pin a worker forever.
- Distinctive: line 207
Do not wipe existing outputs before a re-extract attempt.
- Very specific: line 208
Do not assume status=active means the URL works — verify with the first read, retry-by-reactivate on failure.
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
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 statusPhase 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 taskHash 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 doneAlways 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.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

