Uploading COGs to Object Storage with Python

A four-gigabyte orthomosaic uploaded over a site connection is a twenty-minute operation that fails at minute eighteen more often than anybody expects. The difference between a pipeline that copes and one that does not is entirely in how the upload is structured — and the structure that copes is also the one that verifies what arrived.

This page covers multipart uploads, the headers that matter, and the integrity check that catches a truncated transfer before a STAC item is written against it. It belongs to publishing rasters to STAC and object storage.

Why multipart is not optional

A single-request upload of a large file has three properties that make it unsuitable for survey deliverables: it cannot resume, it cannot parallelise, and many endpoints reject it outright above five gigabytes.

Multipart splits the object into parts uploaded independently. A failed part is retried on its own rather than restarting the transfer. Parts upload concurrently, which on a connection with any latency is where most of the speed comes from. And the completion step is atomic — the object does not exist at its key until every part has arrived, so a consumer never sees a half-written raster.

Single-request upload compared with a multipart upload on failure Two rows show what happens when a transfer fails part way through. The single-request row shows one long bar with a failure marked near its end and an annotation that the entire transfer restarts, losing all progress. The multipart row shows the same object divided into eight parts, with one part marked failed and an annotation that only that part is retried while the others are already complete. A closing note states that multipart completion is atomic, so the object does not appear at its key until every part has arrived and a consumer never sees a half-written raster. single request fails at 90% → restart from zero multipart retry this one others already done Completion is atomic: the object does not exist at its key until every part has arrived.

Figure 1 — The failure behaviour is the reason, not the speed.

Minimal reproducible solution

boto3’s transfer configuration handles the mechanics; the value of wrapping it is in the headers and the verification around it.

import hashlib
from pathlib import Path

import boto3
from boto3.s3.transfer import TransferConfig
from botocore.config import Config

COG_TYPE = "image/tiff; application=geotiff; profile=cloud-optimized"

TRANSFER = TransferConfig(
    multipart_threshold=64 * 1024 * 1024,   # anything above 64 MB goes multipart
    multipart_chunksize=32 * 1024 * 1024,   # 32 MB parts: few enough requests,
    max_concurrency=8,                      # small enough to retry cheaply
    use_threads=True,
)


def storage_client(endpoint_url: str | None = None):
    """An S3 client with retry behaviour suited to a site connection.

    The adaptive retry mode backs off on throttling rather than hammering, and
    ten attempts is not excessive when a single part failure would otherwise
    strand a twenty-minute upload.
    """
    return boto3.client(
        "s3",
        endpoint_url=endpoint_url,
        config=Config(retries={"max_attempts": 10, "mode": "adaptive"}),
    )


def upload_cog(client, path: str, bucket: str, key: str, *,
               cache_seconds: int = 31536000) -> dict:
    """Upload one COG with the headers a web client needs.

    A year-long max-age is safe here only because keys are never overwritten —
    a reprocessed raster goes to a new key. Under an overwrite policy this same
    header makes stale pixels unevictable for a year.
    """
    digest = sha256_of(path)
    client.upload_file(
        Filename=path,
        Bucket=bucket,
        Key=key,
        ExtraArgs={
            "ContentType": COG_TYPE,
            "CacheControl": f"public, max-age={cache_seconds}, immutable",
            "Metadata": {"sha256": digest},
        },
        Config=TRANSFER,
    )
    return {"key": key, "sha256": digest, "bytes": Path(path).stat().st_size}


def sha256_of(path: str, *, block: int = 1 << 20) -> str:
    """Digest the local file in blocks, so memory does not scale with size."""
    h = hashlib.sha256()
    with open(path, "rb") as handle:
        for chunk in iter(lambda: handle.read(block), b""):
            h.update(chunk)
    return h.hexdigest()

Storing the digest in the object’s metadata is what makes verification possible later without keeping a separate record. It travels with the object, survives copies between buckets, and costs nothing.

The headers that matter

Content-Type. image/tiff; application=geotiff; profile=cloud-optimized is precise and understood by tooling. The generic application/octet-stream works for a direct read and loses information every intermediary could have used.

Cache-Control. Long and immutable for deliverables, short for catalogue JSON. The asymmetry is the point: pixels never change at a given key, metadata does.

Metadata. The digest, and usefully the survey identifier. Object metadata is returned by a HEAD request, so a consumer can check both without transferring a byte of content.

Content-Encoding. Leave it unset. A COG is already compressed internally, and gzipping it at the transport layer defeats range requests entirely — the server can no longer serve an arbitrary byte range without decompressing the whole object.

The last of these causes a specific and confusing failure: a COG that works when downloaded whole and fails in every web client, because range requests against a transport-compressed object return the wrong bytes.

Headers set on a published COG and what each enables or breaks Four headers listed with their effect. Content-Type set to the cloud-optimized GeoTIFF media type is understood by tooling, whereas the generic octet stream type loses information every intermediary could have used. Cache-Control set long and immutable is safe only because keys are never overwritten. Object metadata carrying the digest and survey identifier is returned by a HEAD request, so a consumer can check both without transferring content. Content-Encoding must be left unset, because transport compression defeats range requests and produces a COG that downloads correctly and fails in every web client. Content-Type: image/tiff; application=geotiff; profile=cloud-optimized understood by tooling; the generic octet-stream type loses that information Cache-Control: public, max-age=31536000, immutable safe only because keys are never overwritten; catalogue JSON gets the opposite Metadata: sha256, survey id returned by HEAD, so both are checkable without transferring content Content-Encoding: leave unset transport compression defeats ranges: downloads fine, fails in every web client

Figure 2 — Four headers, one of which breaks everything if set.

Tuning part size and concurrency

The two transfer parameters interact, and the usual instinct — make the parts bigger — is the wrong lever on the connections survey work actually runs over.

Throughput on a single stream is bounded by the bandwidth-delay product: how much data can be in flight before an acknowledgement returns. On a link with 80 ms round-trip latency, one stream saturates at a few tens of megabits regardless of the available bandwidth. Adding streams multiplies that; making each stream’s chunks larger does not.

def suggest_transfer_config(file_bytes: int, rtt_ms: float,
                            link_mbps: float) -> TransferConfig:
    """Pick part size and concurrency from the connection, not from habit.

    Concurrency is derived from how many streams it takes to fill the
    bandwidth-delay product; part size is then chosen so the object divides
    into enough parts to keep every stream busy, without dropping below the
    size where request overhead starts to dominate.
    """
    per_stream_mbps = max(1.0, 8 * 64 * 1024 / max(rtt_ms, 1.0) / 1000)
    concurrency = int(min(16, max(4, link_mbps / per_stream_mbps)))

    target_parts = concurrency * 4
    part = file_bytes // max(target_parts, 1)
    part = min(64 * 1024 * 1024, max(16 * 1024 * 1024, part))

    return TransferConfig(
        multipart_threshold=64 * 1024 * 1024,
        multipart_chunksize=int(part),
        max_concurrency=concurrency,
        use_threads=True,
    )

Two limits bound the useful range. Below 16 MB the per-request overhead becomes a measurable fraction of the transfer on a high-latency link. Above 64 MB a failed part costs more to retry than it saved, and on a genuinely unreliable connection the retry cost is the dominant term in total upload time rather than an edge case.

Concurrency has its own ceiling, and it is usually local: eight to sixteen streams will saturate most site connections, and beyond that the threads contend for the same bandwidth while multiplying the chance that one of them trips a throttling response. If throughput plateaus as concurrency rises, the bottleneck has moved and more streams will make it worse.

Where a large upload fails, and what each failure leaves behind Three rows. A network interruption mid-transfer leaves an incomplete multipart upload whose parts remain billable until a lifecycle rule aborts them, and leaves no object at the key, which is the correct behaviour. A throttling response mishandled leaves the transfer retried aggressively into a longer throttle, which presents as an upload that never finishes rather than as an error. A file still being written by the processing run leaves an object that uploads successfully and whose digest does not match, which is caught only by verifying afterwards. a network interruption incomplete parts, billable until a lifecycle rule aborts them throttling mishandled aggressive retries into a longer throttle — never finishes, never errors the file still being written uploads successfully with a mismatched digest Only the first announces itself. The other two need a lifecycle rule and a verification step.

Figure 3 — Three failures, one of which is visible without looking.

Edge-case matrix

Situation Effect Handling
Single-request upload, 4 GB Fails and restarts Multipart with retries
Part size under 8 MB Request overhead dominates 16–64 MB parts
Content-Encoding: gzip Range requests return wrong bytes Never set it
Long cache with overwrites Stale pixels for a year New key per version
No digest stored Truncation undetected Digest into object metadata
Abandoned multipart uploads Silent storage cost Lifecycle rule to abort them
Credentials in code Leaked on commit Environment or instance role
Archive tier for a live asset Hours of retrieval latency Standard tier for served data

The abandoned-upload row is a real and invisible cost. A multipart upload that fails and is never completed or aborted leaves its parts billable indefinitely; a bucket lifecycle rule aborting incomplete uploads after seven days costs nothing to add and prevents a bill that nobody can account for.

Verification snippet

def verify_upload(client, bucket: str, key: str, *,
                  expected_sha256: str, expected_bytes: int) -> dict:
    """Confirm what arrived matches what was sent, without downloading it.

    A HEAD returns the size and the metadata digest, which together catch the
    two failures that matter: a truncated transfer and a completed upload of
    the wrong file. Both are cheap to detect now and expensive to discover
    after a STAC item has been published against them.
    """
    head = client.head_object(Bucket=bucket, Key=key)
    problems = []

    if head["ContentLength"] != expected_bytes:
        problems.append(
            f"size {head['ContentLength']} != expected {expected_bytes}")

    stored = head.get("Metadata", {}).get("sha256")
    if stored is None:
        problems.append("no sha256 in object metadata")
    elif stored != expected_sha256:
        problems.append("digest mismatch — the object is not the file sent")

    if head.get("ContentEncoding"):
        problems.append(
            f"Content-Encoding is {head['ContentEncoding']} — ranges will break")

    if not head.get("ContentType", "").startswith("image/tiff"):
        problems.append(f"unexpected Content-Type {head.get('ContentType')}")

    return {"ok": not problems, "problems": problems,
            "etag": head.get("ETag")}

Note that the ETag is deliberately not used as the integrity check. On a multipart upload it is a digest of digests, not of the content, so it cannot be compared against a local file’s hash — a mismatch tells you nothing and a match is coincidence.

When to escalate

  • Uploads fail consistently at the same size. Suspect an intermediary with a request-size limit rather than the connection. Reducing the part size often resolves it immediately.
  • The digest mismatches reproducibly. The local file is being modified during upload, usually because the processing run has not actually finished writing it. Add a completion barrier.
  • Throughput is far below the link capacity. Raise concurrency before raising part size; on a high-latency link the limit is almost always the number of requests in flight.

Publishing Rasters to STAC and Object Storage