Sizing Partitions to Avoid Small-File Overhead in Cold Storage

Partition pruning gets better as partitions get smaller, right up to the point where the archive is paying more in per-object charges than it saves in avoided reads. This walkthrough is for the engineer who has chosen a partitioning scheme and now needs to pick the resolution that minimises total cost rather than bytes scanned — a different optimum, and one that moves with the storage class the data will live in.

The Three Charges That Scale with Object Count

Volume-based reasoning misses these entirely, which is why an archive can be optimised for scan efficiency and still cost more than the version it replaced.

Charges that scale with object count Per-object metadata overhead, minimum billable object size, and per-request charges, with the classes each applies to and the effect on an archive of many small objects. chargeapplies toeffect at 1 M objects of 8 MB per-object metadata overhead 32 KB + 8 KB index GLACIER, DEEP_ARCHIVE +40 GB billed, ~0.5% here minimum billable size 128 KB STANDARD_IA, GLACIER_IR no effect at 8 MB; brutal below 128 KB per-request charges write, restore, list, read every class the dominant term — see below Only the third scales with how often the archive is used; the first two are paid whether or not anyone reads it.

Step-by-Step Procedure

Step 1 — Model total cost, not bytes scanned

# Monthly cost of one partitioning resolution, all terms included
def monthly_cost(total_tb, objects, queries_per_month, partitions_per_query,
                 storage_per_gb=0.0036, restore_per_gb=0.01,
                 restore_req=0.05 / 1000, overhead_kb=40):
    stored_gb = total_tb * 1024 + objects * overhead_kb / 1e6
    storage = stored_gb * storage_per_gb
    gb_per_partition = total_tb * 1024 / objects
    restored_gb = queries_per_month * partitions_per_query * gb_per_partition
    retrieval = restored_gb * restore_per_gb
    requests = queries_per_month * partitions_per_query * restore_req
    return {"storage": storage, "retrieval": retrieval,
            "requests": requests, "total": storage + retrieval + requests}

for res, objs, ppq in [("res 4", 32, 1), ("res 5", 210, 2), ("res 6", 1_400, 4),
                       ("res 7", 9_800, 11), ("res 8", 68_000, 34)]:
    c = monthly_cost(450 / 1024 * 1024, objs, 4_000, ppq)
    print(f"{res:>6}  objects={objs:>6}  total=${c['total']:8.2f}  "
          f"(storage ${c['storage']:.2f} · retrieval ${c['retrieval']:.2f} · requests ${c['requests']:.2f})")

The partitions_per_query column is what makes finer resolutions worse rather than better past a point: a query covering a fixed ground area intersects more partitions as they shrink, so requests grow while each restore shrinks.

Step 2 — Find where the curve turns

Total monthly cost against partition resolution A U-shaped total cost curve across five resolutions, with retrieval dominating at coarse resolutions and request charges plus per-object overhead dominating at fine ones, and a floor at resolution six. $220$150$800 $218 $96 $71 · minimum $88 $194 retrieval dominates requests + overhead dominate res 4res 5res 6 res 7res 8 32 obj2101,400 9,80068,000 The floor is broad: resolutions 5 to 7 are all within 35% of each other, so the decision is robust to modelling error.

Step 3 — Merge sparse partitions rather than lowering the resolution globally

Population-weighted data produces partitions differing by two orders of magnitude at one resolution. Merging the sparse ones preserves pruning where the data is dense and removes the object count where it is not.

# Merge neighbouring partitions below the floor, keeping the parent cell as the key
import h3, collections

sizes = {cell: bytes_for(cell) for cell in partitions}       # from the inventory
FLOOR = 200 * 1024**2

merged, buckets = {}, collections.defaultdict(list)
for cell, size in sizes.items():
    if size >= FLOOR:
        merged[cell] = [cell]
    else:
        buckets[h3.cell_to_parent(cell, h3.get_resolution(cell) - 1)].append(cell)

for parent, children in buckets.items():
    merged[parent] = children          # one object per parent, named by the parent cell
print(f"{len(sizes)} partitions -> {len(merged)} objects")

Validation & Verification

# Object size distribution after merging: nothing below the floor, nothing above the ceiling
aws s3api list-objects-v2 --bucket spatial-archive --prefix archive/parcels/2026/ \
  --query 'Contents[].Size' --output text | tr '\t' '\n' \
  | awk '{s[NR]=$1} END {n=asort(s); print "min", s[1]/1048576 "MB",
          "p50", s[int(n/2)]/1048576 "MB", "max", s[n]/1048576 "MB", "count", n}'

Expected output shows a minimum above the merge floor, a maximum below the object ceiling, and a count close to the modelled optimum. A minimum far below the floor means the merge step did not run on some prefix, which is the usual cause of a cost model that stops predicting the invoice.

Troubleshooting

Symptom Root cause Fix
Request charges rising faster than volume Resolution too fine for the query pattern Model total cost; move one resolution coarser or merge sparse cells
A few enormous partitions Population-weighted density at a uniform resolution Split only the outliers; do not raise the resolution globally
Merged partitions break pruning Merge key not derivable from the query’s cell Merge to the parent cell so the hierarchy still resolves
Cost model diverges from the invoice Per-object overhead or minimum billable size omitted Add both terms; they are invisible in a volume-only model
Optimum shifts after a class change Restore and request prices differ per class Re-run the model whenever the storage class changes

Operational Execution Checklist

Monitoring Object Size After the Decision

Partition size drifts as data grows, and an archive that sized its partitions once will find them outside the target window a few years later. Three measures, reported monthly from the inventory, catch the drift while it is still cheap to correct.

Three measures that catch partition drift Three inventory-derived measures with their target and the action each triggers when it drifts. measure target action when it drifts median object size 200 MB – 2 GB adjust resolution or merge floor p95 ÷ median ratio under 12:1 split the outlier partitions objects below the floor near zero re-run the merge step objects above the ceiling zero split by a secondary key All three come from the inventory report the archive already produces, so the monitoring costs one query a month.

Report them per collection rather than for the archive as a whole. A blended median hides the one collection whose growth has pushed it out of the window, which is exactly the collection the monitoring exists to find.

Frequently Asked Questions

What object size should the merge floor be set to?

Between roughly 200 MB and 400 MB for archive classes, which keeps per-object overhead under a fraction of a percent and keeps restore counts manageable. Below about 50 MB the overhead and request charges start to be visible in the invoice; below the class’s minimum billable size they dominate outright.

Does merging hurt pruning?

Slightly, and much less than the object-count saving is worth. A merged object covers a parent cell rather than a child cell, so a query that would have touched one child now touches its siblings too — but those siblings were sparse by definition, so the extra bytes are small. The pruning that matters is in the dense partitions, which are never merged.

How does this interact with row-group sizing?

They are the same trade at two scales. Object size governs what a restore costs; row-group size governs what a read of the restored copy costs. Choose the object size first from the cost model, then size row groups inside it against the scan pattern — which for archive-class data means larger groups, since partial reads are not available until the restore completes.

Does the optimum shift as query patterns change?

Yes, and query rate moves it more than query shape. An archive read four thousand times a month has its floor further right — larger partitions, fewer objects — than the same archive read forty thousand times, because retrieval scales with reads while per-object overhead does not. Re-run the model when the read rate changes by more than about a factor of two.

How does compaction fit with partition sizing?

Compaction is what maintains the sizing decision over time. Partitions grow as data is added and fragment as data is corrected, so the distribution drifts away from the target window without anyone changing the resolution. A scheduled compaction that rewrites partitions outside the window keeps the archive at the size it was designed for.

Is there a lower bound below which no archive should go?

Below roughly 50 MB per object the per-request and overhead terms start to dominate in every archive class, and below the class’s minimum billable size they dominate outright. That gives a practical floor around 100 MB for archive-class storage, well above the technical minimum and well below the point where restores become coarse.

Does the model apply to the delivery copies as well as the archive?

The structure does, with different rates. A delivery copy in an instant-access class has no restore term and higher per-gigabyte storage, which moves the floor toward smaller objects because retrieval is cheap and selectivity is worth more. Running the same model with the delivery class’s rates usually produces a partition resolution one step finer than the archive’s, which is a legitimate difference rather than an inconsistency.

How is the model kept honest as prices change?

By keeping the rates in a configuration file rather than in the code, and by re-running the model annually against current published prices. Rate changes are infrequent and occasionally significant, and a model whose inputs were correct three years ago will quietly recommend the wrong resolution without ever failing.

Does the same reasoning apply to the number of files within a partition?

Yes, one level down. A partition made of many small files pays the same per-request overhead as an archive made of many small partitions, and compaction is the same remedy. Size files against the byte target and partitions against the query extent, and the two decisions stay independent.

Up one level: Spatial Partitioning Techniques.