Converting GeoTIFF to Cloud-Optimized GeoTIFF at Scale
Converting one scene is a command; converting sixty thousand is a system. This walkthrough is for the data engineer running a raster migration across a multi-terabyte imagery holding, where the job will be interrupted, some inputs will be malformed, and the conversion must be restartable without producing duplicates or half-written objects. The pipeline below fans out per scene, validates before and after, quarantines what it cannot handle, and leaves a manifest that explains every output it produced.
Sizing the Job Before Starting It
Raster conversion is CPU-bound on compression and I/O-bound on transfer, and the ratio between them decides whether to run it on many small workers or few large ones. Measure on a representative sample rather than extrapolating from one scene, because compression time varies by a factor of five across imagery types.
Step-by-Step Procedure
Step 1 — Inventory and triage the inputs
Enumerate what is actually there before converting any of it. Legacy raster holdings routinely contain files that are not what their extension claims, scenes with no georeferencing, and duplicates under different names.
# Probe every input's essential properties without reading pixel data
find datasets/imagery/raw -name '*.tif' -print0 \
| xargs -0 -P 16 -I{} sh -c '
gdalinfo -json "{}" 2>/dev/null \
| jq -c --arg p "{}" "{path:\$p, size:.size, bands:(.bands|length),
dtype:.bands[0].type, crs:(.coordinateSystem.wkt!=null),
blocks:.bands[0].block}" \
|| echo "{\"path\":\"{}\",\"error\":\"unreadable\"}"' \
> inventory.ndjson
jq -s 'group_by(.dtype) | map({dtype: .[0].dtype, n: length})' inventory.ndjson
Step 2 — Convert with idempotent output keys
Each scene’s output key is a pure function of its input identity, and a completion marker is written last so a restart can skip finished work safely.
# convert.py — one scene; the fleet runs this concurrently
import hashlib, subprocess, boto3, os, json
s3 = boto3.client("s3")
BUCKET, PREFIX = "spatial-archive", "archive/imagery/2026/"
def output_key(src: str) -> str:
stem = os.path.splitext(os.path.basename(src))[0]
return f"{PREFIX}{stem}.tif"
def already_done(key: str) -> bool:
try:
s3.head_object(Bucket=BUCKET, Key=key + ".done")
return True
except s3.exceptions.ClientError:
return False
def convert(src: str, dtype: str) -> dict:
key = output_key(src)
if already_done(key):
return {"src": src, "key": key, "status": "skipped"}
predictor = "3" if dtype.startswith("Float") else "2"
resampling = "NEAREST" if "classified" in src else "AVERAGE"
tmp_key = key + ".tmp"
subprocess.run([
"gdal_translate", src, f"/vsis3/{BUCKET}/{tmp_key}", "-of", "COG",
"-co", "BLOCKSIZE=512", "-co", "COMPRESS=DEFLATE",
"-co", f"PREDICTOR={predictor}", "-co", "LEVEL=9",
"-co", "OVERVIEWS=IGNORE_EXISTING",
"-co", f"OVERVIEW_RESAMPLING={resampling}",
"-co", "BIGTIFF=YES", "-co", "NUM_THREADS=ALL_CPUS",
], check=True)
# Promote only after the object is complete: a key that exists is always whole
s3.copy_object(Bucket=BUCKET, Key=key,
CopySource={"Bucket": BUCKET, "Key": tmp_key},
ChecksumAlgorithm="SHA256")
s3.delete_object(Bucket=BUCKET, Key=tmp_key)
s3.put_object(Bucket=BUCKET, Key=key + ".done",
Body=json.dumps({"src": src, "predictor": predictor,
"resampling": resampling}).encode())
return {"src": src, "key": key, "status": "converted"}
Step 3 — Validate every output before it counts as done
Validation runs inside the same job, so a scene that fails never gets a completion marker and will be retried.
rio cogeo validate "/vsis3/spatial-archive/archive/imagery/2026/ortho_n5432_e0871.tif" \
&& gdalinfo -json "/vsis3/spatial-archive/archive/imagery/2026/ortho_n5432_e0871.tif" \
| jq -e '.bands[0].block == [512,512] and (.bands[0].overviews | length) >= 4'
Validation & Verification
Reconciliation at the end is what proves the job finished, and it is a count comparison rather than a spot check.
INPUTS=$(wc -l < inventory.ndjson)
DONE=$(aws s3api list-objects-v2 --bucket spatial-archive \
--prefix archive/imagery/2026/ --query 'length(Contents[?ends_with(Key, `.done`)])')
QUARANTINED=$(aws s3api list-objects-v2 --bucket spatial-archive \
--prefix quarantine/imagery/ --query 'length(Contents)')
echo "inputs=$INPUTS done=$DONE quarantined=$QUARANTINED gap=$((INPUTS - DONE - QUARANTINED))"
Expected output has a gap of zero. Every input either produced a validated output or is sitting in quarantine with its error — no third state exists, which is what makes the job’s completion assertable rather than assumed.
Troubleshooting
| Symptom | Root cause | Fix |
|---|---|---|
| Output larger than the input | Predictor wrong for the data type | Predictor 3 for floating point, 2 for integer; re-convert affected scenes |
| Overviews missing on some outputs | Source contained overviews and the driver preserved them | Use OVERVIEWS=IGNORE_EXISTING so they are always regenerated |
| Job fails near completion on large scenes | BigTIFF threshold crossed unexpectedly | Set BIGTIFF=YES unconditionally for archival conversions |
| Workers idle while transfer saturates | Fleet sized on CPU alone | Cap concurrency at the point network throughput plateaus |
| Duplicate outputs after a restart | Output key derived from a timestamp or job id | Derive it from the input identity only |
Operational Execution Checklist
Watching the Job Without Watching the Logs
A conversion fleet running for twelve hours produces more log output than anyone reads, and the useful signals are four counters rather than a stream. Publishing them as the job runs turns “is it working?” into a glance.
Emit the counters to whatever the team already watches rather than building a dashboard for one job. The point is that a long-running conversion should be legible to someone who was not there when it started — including the person who has to decide, at hour nine, whether to let it finish.
Frequently Asked Questions
Should conversion happen before or after the imagery is tiered?
Before, always. Converting a scene already in an archive class costs a restore, and if that object has not met its minimum storage duration, an early-deletion charge on top. Convert at ingest — or, for a backlog, while the material is still in an instant-access class — and let the lifecycle rules tier the converted result.
How are multi-file scenes with sidecars handled?
Fold the sidecar content into the output before discarding it. World files, .aux.xml statistics and .prj files all carry information the COG can hold internally, and a conversion that leaves them behind loses georeferencing or statistics silently. Where the sidecar carries free-text provenance, move it into the metadata record rather than into the raster.
Can the conversion run against imagery already in object storage?
Yes, and it is usually the right arrangement — GDAL reads and writes through the virtual filesystem, so no scene is ever staged on local disk. Run the workers in the same region as both buckets, or the job pays egress on every byte it reads and writes.
How should the job handle scenes that are already valid COGs?
Detect and skip them rather than re-converting. A re-conversion of an already-compliant scene costs compute and produces a byte-different file with a new checksum, which breaks any integrity record referencing the original. Validate first, convert only what fails, and record the skip so the reconciliation still accounts for every input.
Does the fleet need to run in the same account as the archive?
It needs credentials into the archive and network proximity to it; the account is a matter of preference. Running conversion in a separate account with write access to a staging prefix is a reasonable separation, and it means a runaway job cannot touch the published archive directly. What matters more is the region, since cross-region conversion pays egress on every byte read and written.
What is a reasonable failure rate to accept before stopping the job?
Around one to two percent for legacy holdings, and near zero for a modern feed. The useful discipline is a rising-rate alarm rather than an absolute threshold: a job that has been quarantining 0.3% for six hours and suddenly quarantines 40% has met a batch that differs from everything before it, and stopping to look is cheaper than converting it wrongly.
How is the job’s output verified against the inputs at the end?
By the reconciliation described above — inputs equals outputs plus quarantine, with no third state. That single assertion is what makes the job’s completion a fact rather than an impression, and it is worth failing the run on rather than reporting as a warning.
Should the conversion write to the final bucket or to a staging one?
Either works provided the promotion step is atomic. Writing to a staging prefix in the same bucket is simpler and keeps the copy local; a separate staging bucket adds isolation at the cost of a cross-bucket copy. What matters is that no key in the published prefix ever holds a partially written or unvalidated object.
Related
- Cloud-Optimized GeoTIFF Conversion Pipelines — the parent topic setting out tile size, overview and compression decisions.
- Choosing Overview Levels and Resampling for Archived Imagery — the sibling guide behind the resampling choice in Step 2.
- Validating COG Structure Before Archival — the check the job runs before promoting an object.
- Converting Legacy Shapefiles to GeoParquet at Scale — the same fan-out discipline applied to vector data.
Up one level: Cloud-Optimized GeoTIFF Conversion Pipelines.