Backfilling Historical Cost with Idempotent Batch Jobs
Loading six months of historical database cost into a fresh ledger is a one-shot job that almost never finishes on the first attempt, so this page builds a day-chunked backfill that pages AWS Cost Explorer, upserts on a natural key, and resumes from a checkpoint table without ever double-counting.
Back to: Batch Processing for Historical Metrics
A backfill differs from an incremental pull in one decisive way: it touches a large, closed date range in a single run, so any failure — a throttled API, a dropped connection, a redeploy mid-job — lands you halfway through with no clean way to tell what already committed. The fix is to make the whole job idempotent: chunk the range by day, write each day through an ON CONFLICT DO UPDATE upsert keyed on (tenant_id, resource_id, billing_period, export_version), and record every completed day in a checkpoint table. Re-running then skips finished days and rewrites in-flight ones to the exact same values. This is the batch counterpart to the streaming work in real-time metric streaming setup, and every record it writes must first clear strict schema validation for billing data. Cost Explorer itself restates recent days for up to 72 hours, which is exactly why the upsert must overwrite rather than insert — a resumed run naturally picks up the corrected figures.
Prerequisites
Before running the backfill, confirm the following are in place.
AWS IAM: the job needs read-only Cost Explorer access and nothing else. Attach a policy scoped to the two Cost Explorer read actions; least-privilege scoping here is part of broader access control for cost data.
{ "Version": "2012-10-17", "Statement": [ { "Sid": "CostExplorerRead", "Effect": "Allow", "Action": [ "ce:GetCostAndUsage", "ce:GetCostAndUsageWithResources" ], "Resource": "*" } ] }Python: 3.10 or newer (the code uses modern
typinganddatetimeAPIs).Libraries: install the AWS SDK and the Postgres 3 driver.
pip install "boto3>=1.34" "psycopg[binary]>=3.1"Target schema: a fact table with a natural unique key and a companion checkpoint table.
CREATE TABLE cost_fact ( tenant_id text NOT NULL, resource_id text NOT NULL, billing_period date NOT NULL, -- the day this cost belongs to export_version int NOT NULL, -- bumped when the source restates amount numeric(18,6) NOT NULL, currency text NOT NULL, loaded_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (tenant_id, resource_id, billing_period, export_version) ); CREATE TABLE backfill_checkpoint ( job_id text NOT NULL, billing_period date NOT NULL, rows_written int NOT NULL, completed_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (job_id, billing_period) );
Step-by-Step Implementation
The backfill enumerates the date range one day at a time, checks the checkpoint to skip days already done, pages Cost Explorer for the remaining days, upserts each day’s rows in a single transaction with the checkpoint write, and treats throttling as a retryable pause. Build it in four steps.
Step 1 — Enumerate day chunks and read the checkpoint
Chunking by day gives the smallest safely-restartable unit: a day either commits completely or not at all. Before doing any work, load the set of days this job_id already finished so a resumed run does zero redundant API calls.
import os
from datetime import date, timedelta
import psycopg
def day_range(start: date, end: date) -> list[date]:
"""Inclusive-start, exclusive-end list of days to backfill."""
return [start + timedelta(days=i) for i in range((end - start).days)]
def completed_days(conn: psycopg.Connection, job_id: str) -> set[date]:
"""Days already committed for this job, so a resume can skip them."""
with conn.cursor() as cur:
cur.execute(
"SELECT billing_period FROM backfill_checkpoint WHERE job_id = %s",
(job_id,),
)
return {row[0] for row in cur.fetchall()}
# Expected:
# day_range(date(2026,1,1), date(2026,1,4)) -> [2026-01-01, 2026-01-02, 2026-01-03]
# completed_days(conn, "ce-backfill-2026H1") -> {date(2026, 1, 1)} on a resumed run
Step 2 — Page one day out of Cost Explorer
Cost Explorer returns a NextPageToken when a day’s grouped result exceeds one page, and it raises a throttling fault under load. Fetch a full day — every page — grouping by the dimensions that become your natural key, and let a throttle bubble up as a typed signal the caller retries.
import time
import logging
import boto3
from botocore.exceptions import ClientError
logger = logging.getLogger("ce_backfill")
def fetch_day(client, day: date) -> list[dict]:
"""Return every grouped cost row for a single day, following pagination."""
start = day.isoformat()
end = (day + timedelta(days=1)).isoformat()
rows: list[dict] = []
next_token: str | None = None
while True:
kwargs = {
"TimePeriod": {"Start": start, "End": end},
"Granularity": "DAILY",
"Metrics": ["UnblendedCost"],
"GroupBy": [
{"Type": "TAG", "Key": "tenant_id"},
{"Type": "DIMENSION", "Key": "RESOURCE_ID"},
],
}
if next_token:
kwargs["NextPageToken"] = next_token
resp = client.get_cost_and_usage_with_resources(**kwargs)
for result in resp["ResultsByTime"]:
for group in result["Groups"]:
tenant = group["Keys"][0].removeprefix("tenant_id$") or "untagged"
amount = group["Metrics"]["UnblendedCost"]
rows.append({
"tenant_id": tenant,
"resource_id": group["Keys"][1],
"billing_period": day,
"amount": float(amount["Amount"]),
"currency": amount["Unit"],
})
next_token = resp.get("NextPageToken")
if not next_token:
return rows
A tag group key arrives prefixed as tenant_id$acme; removeprefix strips it, and an empty remainder means the resource carried no tenant_id tag, which we bucket as untagged rather than dropping.
Step 3 — Upsert the day and checkpoint in one transaction
This is the heart of idempotency. Every row goes through ON CONFLICT (tenant_id, resource_id, billing_period, export_version) DO UPDATE, and the checkpoint row is written in the same transaction as the facts. Either the day’s data and its checkpoint both commit, or neither does — there is no window where a day is loaded but unmarked, or marked but unloaded.
UPSERT_SQL = """
INSERT INTO cost_fact
(tenant_id, resource_id, billing_period, export_version, amount, currency)
VALUES (%(tenant_id)s, %(resource_id)s, %(billing_period)s,
%(export_version)s, %(amount)s, %(currency)s)
ON CONFLICT (tenant_id, resource_id, billing_period, export_version)
DO UPDATE SET
amount = EXCLUDED.amount,
currency = EXCLUDED.currency,
loaded_at = now()
"""
CHECKPOINT_SQL = """
INSERT INTO backfill_checkpoint (job_id, billing_period, rows_written)
VALUES (%(job_id)s, %(billing_period)s, %(rows_written)s)
ON CONFLICT (job_id, billing_period)
DO UPDATE SET rows_written = EXCLUDED.rows_written, completed_at = now()
"""
def commit_day(conn, job_id: str, day: date, rows: list[dict], export_version: int):
"""Atomically upsert one day's rows and its checkpoint."""
for r in rows:
r["export_version"] = export_version
with conn.transaction(): # single all-or-nothing unit
with conn.cursor() as cur:
cur.executemany(UPSERT_SQL, rows)
cur.execute(CHECKPOINT_SQL, {
"job_id": job_id,
"billing_period": day,
"rows_written": len(rows),
})
logger.info("day %s committed: %d rows", day, len(rows))
Because the upsert overwrites on the natural key, replaying a day that Cost Explorer has since restated simply writes the corrected amount. Bumping export_version is reserved for the case where you must keep both the old and restated figures side by side for audit; a plain resume leaves it fixed.
Step 4 — Drive the range with resume and throttle handling
The driver skips completed days, retries a throttled day with exponential backoff instead of failing the whole job, and leaves an unfinished day uncheckpointed so the next run retries it cleanly. The control flow is shown below.
from botocore.config import Config
MAX_RETRIES = 5
BASE_DELAY = 2.0
def run_backfill(job_id: str, start: date, end: date, export_version: int = 1) -> None:
"""Backfill [start, end) day by day, resumable and idempotent."""
client = boto3.client(
"ce",
config=Config(retries={"max_attempts": 0}), # we own retry, not botocore
)
with psycopg.connect(os.environ["PG_DSN"]) as conn:
done = completed_days(conn, job_id)
for day in day_range(start, end):
if day in done:
logger.info("day %s already checkpointed, skipping", day)
continue
for attempt in range(MAX_RETRIES):
try:
rows = fetch_day(client, day)
commit_day(conn, job_id, day, rows, export_version)
break
except ClientError as exc:
code = exc.response["Error"]["Code"]
if code in ("ThrottlingException", "LimitExceededException") \
and attempt < MAX_RETRIES - 1:
delay = BASE_DELAY * (2 ** attempt)
logger.warning("throttled on %s, retry in %.0fs", day, delay)
time.sleep(delay)
continue
raise
logger.info("backfill %s complete", job_id)
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
run_backfill("ce-backfill-2026H1", date(2026, 1, 1), date(2026, 7, 1))
Expected output on a run that was interrupted after March and restarted:
day 2026-01-01 already checkpointed, skipping
...
day 2026-03-31 already checkpointed, skipping
day 2026-04-01 committed: 412 rows
day 2026-04-02 committed: 407 rows
...
backfill ce-backfill-2026H1 complete
Verification
Prove the job is idempotent before trusting it against a real ledger.
Run it twice; the fact table must be byte-identical. Snapshot a checksum, re-run, and compare — a correct upsert overwrites in place and adds no rows.
psql "$PG_DSN" -tAc \ "SELECT md5(string_agg(tenant_id||resource_id||billing_period::text||amount::text, ',' ORDER BY 1,2,3)) FROM cost_fact"The digest must match before and after the second run.
Assert the checkpoint covers every day. No gaps means no silently-missing day.
done = completed_days(conn, "ce-backfill-2026H1") assert done == set(day_range(date(2026, 1, 1), date(2026, 7, 1))), "missing days"Inspect one committed row. A healthy fact is shaped like
('acme', 'arn:aws:rds:...:db:prod-1', 2026-04-01, 1, 18.42, 'USD')— the shape the aggregation tier consumes directly.
Gotchas & Edge Cases
- Cost Explorer restates recent days. Figures for the last ~72 hours are provisional and change as usage finalizes. Backfill closed history freely, but re-run the trailing three days on a schedule so the upsert picks up restatements; never treat a fresh day as final.
executemanyis not atomic on its own. Idempotency comes from wrapping the upsert and the checkpoint in a singleconn.transaction(). If you commit them separately, a crash between the two leaves a day loaded but unmarked, and the resume re-loads it — harmless with an upsert, but it defeats the skip optimization.- Let botocore’s built-in retry stay off. Setting
max_attempts=0keeps retry logic in one place. If both botocore and your loop retry, a throttled call is attempted far more often than your backoff schedule intends — the same double-retry trap covered in implementing retry logic for failed metric pulls. GetCostAndUsageWithResourcesonly reaches back 14 days. Resource-level granularity is capped at the last 14 days; for older ranges drop toget_cost_and_usagegrouped by tag and linked account, and accept coarser attribution. Choose the API per chunk based on the day’s age.- Untagged spend must stay visible. Resources missing the
tenant_idtag collapse intountaggedrather than being dropped, so the backfill total always reconciles to the invoice. Tag-propagation lag means recently-created resources may land untagged for the day they were made. - Bump
export_versiononly for deliberate restatements. Changing it mid-backfill forks every row into a new key and doubles your storage. Keep it fixed for a normal load; reserve a bump for when finance needs the pre- and post-correction figures preserved together.
Frequently Asked Questions
Why chunk by day instead of pulling the whole range in one call?
A day is the smallest unit Cost Explorer’s DAILY granularity commits cleanly, and it is the smallest unit you can safely re-run. Pulling a wide range in one call means a failure at 90% forces you to redo everything, and it makes the checkpoint coarse enough to be useless. Day chunks give a precise resume point and keep each transaction small enough to commit quickly under lock.
How is this different from just deleting and re-inserting the range?
Delete-then-insert has a window where the data is gone — any reader or downstream job that runs mid-backfill sees a hole, and a crash after the delete leaves the range empty. An upsert on the natural key never removes committed data; each day transitions atomically from its old values to its new ones, so the ledger is always complete and queryable during a resume.
What makes the re-run produce identical rows?
The natural-key upsert. Because (tenant_id, resource_id, billing_period, export_version) uniquely identifies a fact, a second write with the same key updates in place instead of inserting a duplicate, and Cost Explorer returns the same figure for a closed day every time. Combined with the atomic checkpoint, replaying any subset of days converges the table to exactly one correct state.
Where should record validation happen in this flow?
Between fetch_day and commit_day. Every parsed row should clear strict schema validation for billing data — non-numeric amounts, missing currency, or an out-of-range date — before it reaches the upsert, so a malformed API response is rejected rather than persisted as a bogus charge that a later re-run would faithfully reproduce.
Can I run several date ranges in parallel to finish faster?
Yes, if they use distinct job_id values and disjoint day ranges, because the upsert and checkpoint are keyed to avoid collisions. Watch the Cost Explorer request rate, though: parallel jobs share the account’s throttle budget, so more workers often just trade one long backfill for several throttled ones. Two or three parallel ranges is usually the practical ceiling.
Related
- Partitioning cost time-series as Parquet for Athena — where the backfilled facts go next when the query layer is Athena rather than Postgres.
- Implementing retry logic for failed metric pulls — the general backoff engine that hardens the throttle path used here.
- Batch Processing for Historical Metrics — the parent topic covering large-range historical loads across engines and providers.