Scheduling Sentinel-2 Downloads with Prefect
To download new Sentinel-2 scenes automatically, wrap a STAC search and a download step in a @flow, filter each search against a manifest of already-fetched scene IDs, and attach a CronSchedule through a deployment so it runs on an interval. The manifest is what keeps overlapping scheduled windows from re-downloading the same imagery:
from prefect import flow
@flow(name="s2-downloader")
def download_new_scenes(bbox, out_dir="./data", days=3):
known = load_manifest(out_dir) # set of scene IDs seen before
items = search_recent(bbox, days) # STAC results, last N days
new = [it for it in items if it["id"] not in known]
paths = [fetch_asset.submit(it, out_dir).result() for it in new]
append_manifest(out_dir, [it["id"] for it in new])
return paths
This page expands that skeleton into a production-ready scheduled downloader. It sits under Orchestrating Pipelines with Prefect, which covers the broader flow/task model; here we focus narrowly on the scheduling-and-deduplication problem.
Why This Arises in Remote Sensing Workflows
Sentinel-2 acquisitions arrive on a fixed cadence — a 5-day revisit at the equator, tighter toward the poles — and providers such as Earth Search publish L2A Cloud-Optimized GeoTIFFs within about a day of sensing. A monitoring pipeline needs to notice those new scenes and pull them without a human running a script. The naive approach, re-downloading everything the search returns on every run, wastes egress and clobbers local files.
Two facts make deduplication necessary. First, to be sure you never miss a scene during a transient outage, each run must look back further than its own interval — a daily run typically searches two or three days. That overlap means consecutive runs see the same scenes. Second, STAC returns items in full each time regardless of what you already hold. Without a memory of what has been fetched, the overlap turns into repeated downloads. A manifest — a persisted set of scene IDs — gives the flow that memory.
For the full orchestration context, see the parent section on Cloud Execution & Orchestration; for the search mechanics reused below, the date-and-cloud filtering patterns come from Using pystac-client to Filter Sentinel-2 Imagery by Date.
How the scheduled run deduplicates
The diagram shows one scheduled run. The cron trigger fires; the flow loads the manifest, searches STAC, subtracts what it already has, downloads only the remainder, and writes the new IDs back to the manifest.
Environment & Setup
| Package | Minimum version | Why required |
|---|---|---|
prefect |
2.14 | @flow, @task, .submit, CronSchedule, deployments |
pystac-client |
0.7 | Searches the STAC API for recent Sentinel-2 items |
rasterio |
1.3 | Streams the remote COG asset to local disk |
pip install "prefect>=2.14,<3" "pystac-client>=0.7" "rasterio>=1.3"
Start a local Prefect server (prefect server start) or set PREFECT_API_URL for Prefect Cloud. No API key is needed for the public Earth Search STAC endpoint or the Sentinel-2 Open Data bucket.
Complete Working Example
The script below is a full, deployable downloader. The manifest is a JSON file of scene IDs; the search reuses a lookback window; downloads run in parallel with retries; and only genuinely new scenes touch the network.
import json
from datetime import date, timedelta
from pathlib import Path
import rasterio
from prefect import flow, task, get_run_logger
from prefect.tasks import task_input_hash
from pystac_client import Client
STAC_API = "https://earth-search.aws.element84.com/v1"
MANIFEST = "manifest.json"
@task(name="load-manifest")
def load_manifest(out_dir: str) -> set[str]:
"""Return the set of scene IDs already downloaded (empty on first run)."""
path = Path(out_dir) / MANIFEST
if not path.exists():
return set()
return set(json.loads(path.read_text()))
@task(name="search-recent", retries=2, retry_delay_seconds=15)
def search_recent(bbox: tuple[float, float, float, float], days: int) -> list[dict]:
"""Search STAC for Sentinel-2 L2A scenes over the last `days` days."""
logger = get_run_logger()
end = date.today()
start = end - timedelta(days=days)
client = Client.open(STAC_API)
search = client.search(
collections=["sentinel-2-l2a"],
bbox=bbox,
datetime=f"{start.isoformat()}/{end.isoformat()}",
query={"eo:cloud_cover": {"lt": 30}},
)
# Keep only serializable fields so the result caches and passes cleanly
items = [
{"id": it.id, "href": it.assets["red"].href}
for it in search.items()
]
logger.info("Search window %s..%s returned %d items", start, end, len(items))
return items
@task(
name="fetch-asset",
tags=["s2-download"],
retries=3,
retry_delay_seconds=[10, 30, 90],
cache_key_fn=task_input_hash,
cache_expiration=timedelta(days=14),
)
def fetch_asset(item: dict, out_dir: str) -> str:
"""Stream one COG to disk. Cached by input so a retry never double-fetches."""
logger = get_run_logger()
dest = Path(out_dir) / f"{item['id']}.tif"
with rasterio.open(item["href"]) as src: # /vsicurl/ range-reads the header
profile = src.profile
data = src.read() # pixel fetch happens here
with rasterio.open(dest, "w", **profile) as dst:
dst.write(data)
logger.info("Fetched %s -> %s", item["id"], dest)
return str(dest)
@task(name="append-manifest")
def append_manifest(out_dir: str, new_ids: list[str]) -> None:
"""Merge new scene IDs into the manifest so the next run skips them."""
path = Path(out_dir) / MANIFEST
existing = set(json.loads(path.read_text())) if path.exists() else set()
existing.update(new_ids)
path.write_text(json.dumps(sorted(existing)))
@flow(name="s2-scheduled-downloader")
def download_new_scenes(
bbox: tuple[float, float, float, float],
out_dir: str = "./data",
days: int = 3,
) -> list[str]:
logger = get_run_logger()
Path(out_dir).mkdir(parents=True, exist_ok=True)
known = load_manifest(out_dir)
items = search_recent(bbox, days)
# Set-difference on the scene ID is what deduplicates overlapping windows
new = [it for it in items if it["id"] not in known]
logger.info("%d/%d scenes are new", len(new), len(items))
if not new:
logger.info("Nothing new to download this run.")
return []
futures = [fetch_asset.submit(it, out_dir) for it in new]
paths = [f.result() for f in futures]
append_manifest(out_dir, [it["id"] for it in new])
logger.info("Downloaded %d new scenes", len(paths))
return paths
if __name__ == "__main__":
download_new_scenes(bbox=(11.0, 46.0, 11.5, 46.5))
Run it once by hand to seed the manifest, then run it again immediately — the second run logs 0/N scenes are new and downloads nothing, confirming the deduplication works.
Variant Patterns
1. Attach a cron schedule via a deployment
To let the flow run itself, register a deployment with a CronSchedule. A daily 04:00 UTC run with a 2-day lookback comfortably catches new acquisitions while the manifest absorbs the overlap.
from prefect.deployments import Deployment
from prefect.server.schemas.schedules import CronSchedule
from downloader import download_new_scenes
Deployment.build_from_flow(
flow=download_new_scenes,
name="daily-s2",
schedule=CronSchedule(cron="0 4 * * *", timezone="UTC"),
parameters={"bbox": [11.0, 46.0, 11.5, 46.5], "days": 2},
work_queue_name="downloads",
).apply()
Register with python deploy.py, then run a worker: prefect agent start -q downloads. Prefer IntervalSchedule(interval=timedelta(hours=12)) if you want twice-daily runs spaced from first execution rather than fixed clock times.
2. Store the manifest in shared object storage
A local JSON file breaks when different workers pick up runs. Point the load/append helpers at an S3 object so any worker sees the same state; the flow logic is unchanged.
import json
import boto3
_s3 = boto3.client("s3")
def load_manifest_s3(bucket: str, key: str) -> set[str]:
try:
obj = _s3.get_object(Bucket=bucket, Key=key)
return set(json.loads(obj["Body"].read()))
except _s3.exceptions.NoSuchKey:
return set()
def append_manifest_s3(bucket: str, key: str, new_ids: list[str]) -> None:
ids = load_manifest_s3(bucket, key)
ids.update(new_ids)
_s3.put_object(Bucket=bucket, Key=key, Body=json.dumps(sorted(ids)).encode())
3. Download multiple bands per scene
Real pipelines rarely want only the red band. Expand each item to a list of assets and map fetch_asset across them, keying the manifest by scene ID once all bands land.
BANDS = ["red", "nir", "green"]
@task(name="expand-assets")
def expand_assets(item, out_dir):
return [{"id": f"{item['id']}_{b}", "href": item["assets"][b]} for b in BANDS]
Pair this with a tag-based concurrency limit (prefect concurrency-limit create s2-download 4) so multiplying bands by scenes does not overwhelm the archive.
Common Errors
Every run re-downloads the same scenes
The manifest is not being persisted or not being read. Confirm append_manifest runs after a successful download and that load_manifest targets the same out_dir. If runs land on different workers, move the manifest to shared storage as in Variant 2.
KeyError: 'red' when building items
The asset key differs by STAC provider — Earth Search uses red, others use B04. Print list(item.assets) once to confirm the key name before hard-coding it.
Scheduled deployment exists but nothing runs
A schedule needs a worker polling its work_queue_name. Start prefect agent start -q downloads and verify with prefect deployment inspect s2-scheduled-downloader/daily-s2 that the next run times are populated.
Related
- Orchestrating Pipelines with Prefect — the parent guide covering tasks, flows, mapping, and concurrency limits in depth
- Using pystac-client to Filter Sentinel-2 Imagery by Date — the date-window and cloud-cover filtering the search step relies on
- Querying STAC Catalogs Programmatically — broader STAC search patterns for building the item list
- Retrying Failed Raster Tasks in Prefect — harden the download task against transient S3 errors