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:

  1. Calling Cost Explorer for resource-level chargeback. GetCostAndUsage’s GroupBy parameter accepts at most two dimensions, and none of the built-in dimensions expose a per-resource cost — RESOURCE_ID grouping does not exist in the API the way it does as a CUR column. Teams that try to reconstruct per-instance cost by cross-referencing GroupBy=["SERVICE","USAGE_TYPE"] against a separate resource inventory end up with an approximation, not the ledger figure, and it silently drifts from the invoice.
  2. 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.
  3. Treating Cost Explorer as unmetered. Every GetCostAndUsage page 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 fine GroupBy can 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.

Routing logic between the CUR and Cost Explorer backends A decision diagram. An incoming CostQuery flows into a router. If the query requires resource-level detail or hourly granularity, it routes to the CUR via Athena backend, which reads partitioned Parquet and returns line-item rows including a resource id at Athena scan cost with no per-call fee. Otherwise it routes to the Cost Explorer backend, which calls GetCostAndUsage with cursor pagination and returns daily or monthly aggregated rows at ten cents per hundred paginated requests, available within about a day. CostQuery resource_level? granularity? Facade.fetch() routes by shape AthenaCURBackend resource_level OR HOURLY Athena scan cost, no cap line_item_resource_id included CostExplorerBackend everything else $0.01 / paginated request DAILY / MONTHLY, ~24h fresh CostRecord stream
The façade inspects each CostQuery's granularity and resource_level flag and routes it to whichever backend can satisfy it — never both, and never the wrong one by default.

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 _cur and _ce, and assert resource_level=True and granularity="HOURLY" queries call AthenaCURBackend.fetch, while every other combination calls CostExplorerBackend.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 SERVICE dimension and diff the summed amount per 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 (unblended vs amortized) than the Cost Explorer Metrics selection.
  • Request-cost budget. Assert CostExplorerBackend.billed_requests stays under an expected ceiling for a given query shape in CI — a GroupBy with 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 until NextPageToken is absent, as CostExplorerBackend.fetch does.
  • 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 unblended to amortized cost across backends. Reconciliation diffs blow up when the CUR query and the Cost Explorer Metrics parameter select different cost types — pin both to the same metric before diffing.
  • Not counting Cost Explorer requests. Wide GroupBy combinations over long ranges page silently — track billed_requests per 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.

Up: AWS CUR to Data Lake Pipeline · Home: Cloud Cost Optimization & FinOps Automation