Cost-per-GB Tracking for Conversion Pipelines

To track cost per gigabyte for a Shapefile or GeoJSON to GeoParquet pipeline, divide total spend by source gigabytes converted, and decompose that spend into three components: compute, storage, and egress. Compute is vCPU-seconds multiplied by the instance rate; storage is output gigabyte-months at the tier rate; egress is gigabytes moved across the network at the transfer rate. Attribute each component to the individual dataset that caused it, amortise shared batch overhead across jobs, and always divide by source size rather than compressed output so a better codec shows up as a saving, not an apparent cost increase. This page gives the formula, a ready-to-run cost tracker that emits a per-job record, and the edge cases where naive accounting misleads. It expands on the cost-attribution section of Cost & Observability for Conversion Pipelines.

Quick-Reference Table

Cost component Unit rate basis What to capture per job Rationale / use case
Compute USD per vCPU-second Wall-clock compute seconds Dominates for geometry-heavy inputs and high compression levels
Storage USD per GB-month Output GeoParquet bytes Recurring cost; shrinks directly with a better codec
Egress / transfer USD per GB Bytes read from and written to remote storage Dominates for cross-region reads and frequent re-conversion
Amortised overhead USD per batch, spread per job Batch fixed cost and job count Ensures tiny datasets carry a fair share of startup cost

How the Cost Decomposition Works

The instinct to report a single blended dollars-per-gigabyte number is understandable but hides the lever you actually control. A conversion job’s bill is not one thing; it is three costs with different drivers. Compute scales with how much CPU the job burns, which in turn depends on geometry complexity and the compression level chosen — pushing the codec harder, as explored in ZSTD Compression Levels for Geospatial Data, trades more compute now for less storage later. Storage is recurring: you pay for the output GeoParquet every month it sits in the bucket, so a codec that shrinks output bytes pays back repeatedly. Egress is the quiet one — reading a large Shapefile across regions or re-converting the same dataset nightly can dwarf both compute and storage, and it never shows up if you only measure the compressed result.

Attribution is what turns this from an accounting curiosity into an operational tool. Because the resource counters that feed the bill are captured per job — compute seconds, source bytes, bytes read, output bytes — you can rank datasets by cost, spot the handful that consume most of the budget, and decide whether they warrant a cheaper tier, a different schedule, or an on-off toggle. This is the same per-job record that drives the metrics and dashboards described across the parent cost and observability topic; here the record simply carries its cost fields fully populated.

The final subtlety is denominator choice. Reporting cost against source gigabytes keeps the metric honest: the pipeline was asked to convert a 640 MB Shapefile, and that figure does not change when you swap Snappy for ZSTD. If you instead divide by output bytes, improving compression shrinks the denominator and inflates the apparent cost per gigabyte — punishing exactly the optimisation you want to encourage. Track compression savings as a separate, celebrated line: source bytes minus output bytes, in gigabytes and in avoided storage dollars.

Cost Tracker Implementation

The tracker below computes all three cost components plus amortised overhead, derives dollars per source gigabyte, quantifies compression savings, and returns a flat record ready to append to a ledger or emit as metric dimensions. Rates live in a config object so a pricing change is a single edit.

python
# python>=3.10
from __future__ import annotations

from dataclasses import dataclass, asdict


@dataclass(frozen=True)
class Rates:
    compute_usd_per_sec: float = 0.0000116   # vCPU-second on the chosen instance
    storage_usd_per_gb_month: float = 0.023  # standard object-storage tier
    egress_usd_per_gb: float = 0.09          # cross-network transfer


@dataclass
class CostRecord:
    dataset_id: str
    source_gb: float
    output_gb: float
    compression_ratio: float
    compute_usd: float
    storage_usd_month: float
    egress_usd: float
    amortised_overhead_usd: float
    total_usd: float
    usd_per_source_gb: float
    gb_saved: float


def build_cost_record(
    *,
    dataset_id: str,
    source_bytes: int,
    output_bytes: int,
    bytes_transferred: int,
    compute_seconds: float,
    batch_fixed_usd: float,
    batch_job_count: int,
    rates: Rates = Rates(),
) -> CostRecord:
    """Compute a per-job cost record for one conversion.

    Overhead is amortised evenly across the batch. `bytes_transferred`
    is the sum of remote bytes read and written for this job.
    """
    if source_bytes <= 0:
        raise ValueError("source_bytes must be positive to attribute cost")
    if batch_job_count <= 0:
        raise ValueError("batch_job_count must be at least 1")

    source_gb = source_bytes / 1024**3
    output_gb = output_bytes / 1024**3
    transferred_gb = bytes_transferred / 1024**3

    compute = compute_seconds * rates.compute_usd_per_sec
    storage = output_gb * rates.storage_usd_per_gb_month
    egress = transferred_gb * rates.egress_usd_per_gb
    overhead = batch_fixed_usd / batch_job_count

    total = compute + storage + egress + overhead
    ratio = round(source_bytes / output_bytes, 3) if output_bytes else 0.0

    return CostRecord(
        dataset_id=dataset_id,
        source_gb=round(source_gb, 4),
        output_gb=round(output_gb, 4),
        compression_ratio=ratio,
        compute_usd=round(compute, 6),
        storage_usd_month=round(storage, 6),
        egress_usd=round(egress, 6),
        amortised_overhead_usd=round(overhead, 6),
        total_usd=round(total, 6),
        usd_per_source_gb=round(total / source_gb, 6),
        gb_saved=round(source_gb - output_gb, 4),
    )


if __name__ == "__main__":
    record = build_cost_record(
        dataset_id="parcels_2026",
        source_bytes=640_000_000,
        output_bytes=210_000_000,
        bytes_transferred=850_000_000,
        compute_seconds=42.0,
        batch_fixed_usd=0.12,
        batch_job_count=30,
    )
    print(asdict(record))

Validation

Verify the tracker on a known input before trusting its ledger. For the example call above — a 640 MB source compressed to 210 MB, 42 compute seconds, and a batch overhead of $0.12 spread across 30 jobs — the components should land in these ranges:

markup
compute_usd            ~= 0.00049   (42 s x 0.0000116)
storage_usd_month      ~= 0.0045    (0.1956 GB x 0.023)
egress_usd             ~= 0.0712    (0.7916 GB x 0.09)
amortised_overhead_usd  = 0.004     (0.12 / 30)
total_usd              ~= 0.0802
compression_ratio      ~= 3.05      (640 / 210)
usd_per_source_gb      ~= 0.135     (0.0802 / 0.596 GB)
gb_saved               ~= 0.40      (0.596 - 0.196)

The dominant component here is egress, not compute — a common and instructive result. If your usd_per_source_gb is dominated by transfer cost, the highest-leverage fix is usually co-locating conversion with the source bucket rather than tuning the codec. Recompute the record after any rate change and confirm the total moves in the direction the rate did; a total that drifts independently of its inputs means a rate is hardcoded somewhere it should not be.

Edge Cases and Caveats

Many tiny datasets. When a batch is dominated by small files, fixed overhead — scheduler startup, image pull, idle wait — can exceed the marginal compute and storage cost combined. Even-weighted amortisation then charges a 5 MB Shapefile the same overhead as a 40 GB one, which inflates its cost per gigabyte enormously. Switch to byte-weighted amortisation, or better, batch small inputs together so one worker converts many files and the fixed cost is paid once. The batching mechanics themselves belong to Building Batch Conversion Pipelines with Python.

Retries and partial failures. A job that fails after burning compute, then succeeds on retry, has incurred real cost twice but converted the dataset once. Attribute the wasted compute to the dataset (so retry storms are visible in its cost trend) but count the source gigabytes only once in the denominator. Otherwise a flaky dataset appears artificially cheap per gigabyte precisely because it ran repeatedly.

Cross-region and re-conversion egress. Nightly re-conversion of an unchanged dataset pays the egress cost every run for zero new value. Track transfer bytes per job and flag datasets whose cumulative egress is climbing without a matching change in source size — they are candidates for caching, incremental conversion, or a schedule change rather than a codec tweak.

Frequently Asked Questions

Should cost per gigabyte be based on source size or output size?

Base the headline metric on source gigabytes, because that is the work the pipeline was asked to do and it stays stable when you change codecs. Report output gigabytes and the compression ratio separately. If you divide cost by output size, a better codec makes cost per gigabyte appear to rise even though you spent less overall, which is exactly backwards for decision-making.

How do I attribute shared batch overhead to individual datasets?

Amortise it. Measure the fixed cost of a batch — scheduler startup, container image pull, idle wait — and divide it across the jobs in that batch, weighted either evenly or by source bytes. Weighting by bytes is fairer when file sizes vary by orders of magnitude, because a 5 MB Shapefile should not absorb the same fixed overhead as a 40 GB one.

How often should unit rates be refreshed?

Review them whenever you change instance family, storage tier, or region, and otherwise on a monthly cadence against the actual cloud bill. Keep rates in versioned configuration so a historical cost record can be recomputed against the rates that were in force when the job ran, rather than being silently rewritten by a later price change.


← Back to Cost & Observability for Conversion Pipelines