CUR vs Cost Explorer API for Programmatic Cost Data
Every team building programmatic AWS cost tooling hits the same fork: pull line items from the Cost and Usage Report (CUR) sitting in S3, or call the Cost Explorer GetCostAndUsage API directly. Both return “AWS cost data,” but they are not interchangeable, and picking the wrong one produces either a bill you didn’t expect or a report that is missing the resource-level detail an engineer needs to act on. CUR is a finalized, hourly, resource-level export delivered to S3 as Parquet or gzipped CSV — cheap to query at scale once loaded, but it takes 24–48 hours to settle and requires you to own the ingestion pipeline described in AWS CUR to Data Lake Pipeline. Cost Explorer’s GetCostAndUsage is a managed, paginated, pre-aggregated query API — data is queryable within about a day, no S3 or Glue infrastructure required, but every paginated request costs 0.01 USD, responses cap at 1,000 rows, and there is no per-resource cost column. This page gives you the decision criteria and a single Python interface that picks the right backend automatically instead of hardcoding one API into every pipeline that needs cost figures.
Root Cause & Failure Modes
The failure pattern is almost always a mismatched default, not a missing feature. Three ways teams get this wrong in production:
- Calling Cost Explorer for resource-level chargeback.
GetCostAndUsage’sGroupByparameter accepts at most two dimensions, and none of the built-in dimensions expose a per-resource cost —RESOURCE_IDgrouping does not exist in the API the way it does as a CUR column. Teams that try to reconstruct per-instance cost by cross-referencingGroupBy=["SERVICE","USAGE_TYPE"]against a separate resource inventory end up with an approximation, not the ledger figure, and it silently drifts from the invoice. - Building a CUR pipeline for a same-day dashboard. CUR reports finalize over 24–48 hours as AWS reprocesses credits, refunds, and discount amortization, and the manifest can rewrite whole days multiple times during that window. A team that stands up S3 + Glue + Athena just to answer “what did we spend today” inherits manifest-versioning complexity for a question Cost Explorer answers in one paginated call.
- Treating Cost Explorer as unmetered. Every
GetCostAndUsagepage beyond the included free-tier volume bills 0.01 USD, and pagination is silent — a naive loop that re-queries a wide date range with a fineGroupBycan page hundreds of times per run. At a few dozen scheduled jobs a day, that adds up to a real line item on the AWS bill, ironically inflating the cost of measuring cost.
The fix is not “always use CUR” or “always use Cost Explorer” — it is routing each query by its actual requirements: resource identity and hourly granularity force CUR; everything else should default to Cost Explorer for its lower operational overhead and faster availability.
Production Pipeline Architecture
The decision reduces to five axes. Use this table before writing a single query:
| Dimension | CUR (S3 → Athena) | Cost Explorer API (GetCostAndUsage) |
|---|---|---|
| Granularity | Hourly, resource-level line items | Daily or monthly, dimension-aggregated |
| Latency | 24–48h to finalize; the manifest can rewrite a day several times during that window | Queryable within roughly 24h; no report-finalization wait |
| Resource-level detail | Yes — the line_item_resource_id column ties every row to an ARN |
No — GroupBy caps at two dimensions and none resolve to a resource |
| Cost per query | Athena scan cost only (~5 USD/TB scanned with columnar pruning), no per-call fee | 0.01 USD per paginated request beyond the included quota |
| Row / response limits | None — full table scan, partition-pruned by day | 1,000 rows per page; wide date ranges force a pagination loop |
| Ideal use case | Audit-grade reconciliation, resource-level chargeback, ML training sets | Dashboards, daily budget checks, low-latency automation |
This is the same trade-off the parent pipeline makes explicit: CUR is the acquisition path for the authoritative, resource-level record, while AWS Cost Explorer Architecture documents the managed aggregate path built for daily reconciliation and executive refreshes. Once a query lands in CUR, its Parquet parsing follows the memory-bounded pattern in Parsing AWS CUR Parquet Files with Python Pandas; once it lands in Cost Explorer, it is already normalized and ready for the allocation stage that the parent Cloud Billing Data Ingestion & Parsing reference defines. A mature pipeline runs both behind one interface, so callers request cost data by shape — not by AWS API — and the routing logic below decides which backend actually answers the call.
Step-by-Step Python Implementation
The module below exposes one CostDataFacade.fetch() call to every caller. Internally it holds two backends behind a shared CostDataBackend interface — AthenaCURBackend for resource-level or hourly reads, CostExplorerBackend for everything else — and both retry AWS throttling with the same backoff decorator.
"""Facade over two AWS cost-data backends: CUR-via-Athena and the Cost Explorer API.
Callers depend on one CostDataProvider-style interface; the facade picks the
backend based on the query shape so pipelines never hardcode which AWS API
their cost figures came from.
"""
from __future__ import annotations
import logging
import time
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import date, timedelta
from typing import Iterator, Optional
import boto3
from botocore.exceptions import ClientError
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
logger = logging.getLogger(__name__)
ATHENA_DATABASE = "finops_billing"
ATHENA_TABLE = "cur_parquet"
ATHENA_OUTPUT_S3 = "s3://finops-athena-results/cur-vs-ce/"
CE_REQUEST_COST_USD = 0.01 # billed per paginated GetCostAndUsage call beyond the free quota
def with_retries(max_attempts: int = 5, base_delay: float = 1.5):
"""Exponential backoff shared by the Athena and Cost Explorer clients."""
def decorator(fn):
def wrapped(*args, **kwargs):
for attempt in range(1, max_attempts + 1):
try:
return fn(*args, **kwargs)
except ClientError as exc:
code = exc.response.get("Error", {}).get("Code", "")
retryable = code in ("ThrottlingException", "TooManyRequestsException")
if not retryable or attempt == max_attempts:
raise
delay = base_delay * (2 ** (attempt - 1))
logger.warning("Throttled (%s), retry %d/%d in %.1fs", code, attempt, max_attempts, delay)
time.sleep(delay)
return wrapped
return decorator
@dataclass
class CostQuery:
start_date: date
end_date: date
granularity: str = "DAILY" # DAILY | MONTHLY | HOURLY
group_by: list[str] = field(default_factory=lambda: ["SERVICE"])
resource_level: bool = False # True forces the CUR/Athena backend
@dataclass
class CostRecord:
period_start: str
dimension_key: str
amount: float
currency: str
resource_id: Optional[str] = None
source: str = ""
class CostDataBackend(ABC):
"""One programmatic-cost-data interface, two AWS implementations."""
@abstractmethod
def fetch(self, query: CostQuery) -> Iterator[CostRecord]:
...
class AthenaCURBackend(CostDataBackend):
"""Resource-level, hourly-capable reads against the CUR Parquet lake."""
def __init__(self, athena_client=None):
self.client = athena_client or boto3.client("athena")
@with_retries()
def _start_query(self, sql: str) -> str:
resp = self.client.start_query_execution(
QueryString=sql,
QueryExecutionContext={"Database": ATHENA_DATABASE},
ResultConfiguration={"OutputLocation": ATHENA_OUTPUT_S3},
)
return resp["QueryExecutionId"]
def _wait_for_completion(self, execution_id: str, poll_seconds: float = 2.0) -> None:
while True:
status = self.client.get_query_execution(QueryExecutionId=execution_id)
state = status["QueryExecution"]["Status"]["State"]
if state == "SUCCEEDED":
return
if state in ("FAILED", "CANCELLED"):
raise RuntimeError(f"Athena query {execution_id} ended in {state}")
time.sleep(poll_seconds)
def fetch(self, query: CostQuery) -> Iterator[CostRecord]:
resource_col = ", line_item_resource_id" if query.resource_level else ""
sql = f"""
SELECT line_item_usage_start_date, product_servicecode,
SUM(line_item_unblended_cost) AS cost, line_item_currency_code{resource_col}
FROM {ATHENA_TABLE}
WHERE line_item_usage_start_date BETWEEN DATE '{query.start_date}' AND DATE '{query.end_date}'
GROUP BY line_item_usage_start_date, product_servicecode, line_item_currency_code{resource_col}
"""
execution_id = self._start_query(sql)
self._wait_for_completion(execution_id)
paginator = self.client.get_paginator("get_query_results")
for page in paginator.paginate(QueryExecutionId=execution_id):
for row in page["ResultSet"]["Rows"][1:]: # skip header row
cells = [c.get("VarCharValue", "") for c in row["Data"]]
yield CostRecord(
period_start=cells[0], dimension_key=cells[1], amount=float(cells[2] or 0),
currency=cells[3], resource_id=cells[4] if query.resource_level else None,
source="cur_athena",
)
class CostExplorerBackend(CostDataBackend):
"""Aggregated, near-real-time reads against GetCostAndUsage."""
def __init__(self, ce_client=None):
self.client = ce_client or boto3.client("ce")
self.billed_requests = 0
@with_retries()
def _page(self, query: CostQuery, next_token: Optional[str]):
kwargs = dict(
TimePeriod={"Start": str(query.start_date), "End": str(query.end_date)},
Granularity=query.granularity,
Metrics=["UnblendedCost"],
GroupBy=[{"Type": "DIMENSION", "Key": k} for k in query.group_by],
)
if next_token:
kwargs["NextPageToken"] = next_token
self.billed_requests += 1 # every page, including the first, is a billed request
return self.client.get_cost_and_usage(**kwargs)
def fetch(self, query: CostQuery) -> Iterator[CostRecord]:
next_token = None
while True:
resp = self._page(query, next_token)
for result in resp["ResultsByTime"]:
for group in result["Groups"]:
metric = group["Metrics"]["UnblendedCost"]
yield CostRecord(
period_start=result["TimePeriod"]["Start"], dimension_key="|".join(group["Keys"]),
amount=float(metric["Amount"]), currency=metric["Unit"], source="cost_explorer",
)
next_token = resp.get("NextPageToken")
if not next_token:
break
logger.info("Cost Explorer billed %d requests (~$%.2f)", self.billed_requests,
self.billed_requests * CE_REQUEST_COST_USD)
class CostDataFacade:
"""Single entry point; routes each query to the backend that can satisfy it."""
def __init__(self):
self._cur = AthenaCURBackend()
self._ce = CostExplorerBackend()
def fetch(self, query: CostQuery) -> Iterator[CostRecord]:
# Resource identity and hourly granularity exist only in CUR.
# Everything else prefers Cost Explorer for lower setup cost and faster availability.
if query.resource_level or query.granularity == "HOURLY":
logger.info("Routing to CUR/Athena (resource_level=%s, granularity=%s)",
query.resource_level, query.granularity)
return self._cur.fetch(query)
logger.info("Routing to Cost Explorer (granularity=%s)", query.granularity)
return self._ce.fetch(query)
if __name__ == "__main__":
facade = CostDataFacade()
# Daily service totals for a dashboard refresh -> Cost Explorer.
dashboard_query = CostQuery(start_date=date.today() - timedelta(days=7), end_date=date.today())
for record in facade.fetch(dashboard_query):
logger.info("%s | %s | $%.2f", record.source, record.dimension_key, record.amount)
# Resource-level audit for a closed month -> CUR/Athena.
audit_query = CostQuery(start_date=date(2026, 6, 1), end_date=date(2026, 6, 30), resource_level=True)
for record in facade.fetch(audit_query):
logger.info("%s | %s (%s) | $%.2f", record.source, record.dimension_key, record.resource_id, record.amount)
Verification & Testing
Confirm the router picks the right backend before it ever touches production AWS accounts:
- Routing assertions. Unit-test
CostDataFacade.fetch()with mock backends substituted for_curand_ce, and assertresource_level=Trueandgranularity="HOURLY"queries callAthenaCURBackend.fetch, while every other combination callsCostExplorerBackend.fetch. This is a pure logic test — no AWS credentials required. - Cross-reconciliation. For a closed billing period, run the same date range through both backends at the
SERVICEdimension and diff the summedamountper service. They should match within the amortization/refund noise Cost Explorer has already absorbed by the time CUR finalizes; a divergence beyond a fraction of a percent usually means the CUR query is summing a different cost type (unblendedvsamortized) than the Cost ExplorerMetricsselection. - Request-cost budget. Assert
CostExplorerBackend.billed_requestsstays under an expected ceiling for a given query shape in CI — aGroupBywith high-cardinality dimensions over a wide date range can silently balloon the page count and the dollar cost of running the pipeline itself.
Common Pitfalls Checklist
- Asking Cost Explorer for a resource ID. It does not exist as a groupable dimension — route resource-level requests to the CUR backend instead of approximating from
USAGE_TYPE. - Ignoring
NextPageToken. A truncated first page silently under-reports cost for wide date ranges — always loop untilNextPageTokenis absent, asCostExplorerBackend.fetchdoes. - Treating CUR’s current day as final. The report can still be rewritten for 24–48 hours — for same-day figures, prefer Cost Explorer, or explicitly mark same-day CUR reads as provisional.
- Comparing
unblendedtoamortizedcost across backends. Reconciliation diffs blow up when the CUR query and the Cost ExplorerMetricsparameter select different cost types — pin both to the same metric before diffing. - Not counting Cost Explorer requests. Wide
GroupBycombinations over long ranges page silently — trackbilled_requestsper run so the automation’s own AWS bill stays visible.
Frequently Asked Questions
Can I get resource-level cost detail from the Cost Explorer API?
No. GetCostAndUsage’s GroupBy parameter supports at most two dimensions from a fixed list (SERVICE, USAGE_TYPE, LINKED_ACCOUNT, and similar), and none of them resolve to an individual resource ARN. Resource-level chargeback requires the CUR’s line_item_resource_id column, queried through Athena as the AthenaCURBackend above does.
Which option is cheaper at scale?
It depends on query volume versus data volume. CUR has no per-call fee — cost is Athena scan volume, which columnar pruning and partitioning keep low — so it wins for high-frequency or large-fanout queries. Cost Explorer’s fee of 0.01 USD per page is cheap for a handful of daily dashboard refreshes but compounds quickly if dozens of jobs each page through wide date ranges with fine-grained GroupBy.
How current is Cost Explorer data compared to CUR?
Cost Explorer data is typically queryable within about 24 hours of usage, without the day being rewritten repeatedly. CUR data for the same period keeps changing for 24–48 hours as AWS applies credits, refunds, and amortized discount corrections, so a CUR read taken too early on a given day is provisional in a way a same-window Cost Explorer read generally is not.
Should a pipeline use both APIs at once?
Yes, and the façade pattern above is exactly why: route daily and monthly dashboard-shaped queries to Cost Explorer to keep the AWS bill and setup cost down, and route resource-level or hourly queries to CUR because that data literally does not exist anywhere else. Reconciling the two periodically, as described in Verification & Testing, catches drift in either path before it reaches a report.
Related
- AWS CUR to Data Lake Pipeline — the manifest-driven ingestion engine that populates the Parquet lake the
AthenaCURBackendqueries. - Parsing AWS CUR Parquet Files with Python Pandas — the memory-bounded parse pattern for the CUR objects Athena scans over.
- AWS Cost Explorer Architecture — the full ingestion engine behind
GetCostAndUsage, including IAM scoping and the 5 requests-per-second throttle. - Cloud Billing Data Ingestion & Parsing — the four-stage acquisition→normalization→allocation→persistence model both backends feed into.
- Cross-Cloud Cost Allocation Strategies — where reconciled cost records from either backend get mapped to owning teams.
Up: AWS CUR to Data Lake Pipeline · Home: Cloud Cost Optimization & FinOps Automation