Get Last Month’s AWS Cost Broken Down by Service (SDK v3)

Printed financial charts and bar graphs spread across a desk

Photo by Nick Chong on Unsplash

To get your AWS cost by service for last month with the SDK, call the Cost Explorer GetCostAndUsage API with a TimePeriod covering the previous calendar month, Granularity: "MONTHLY", a cost metric such as UnblendedCost, and GroupBy on the SERVICE dimension. Each group in the response is one service and its cost. Every API request costs $0.01.

This example is for developers and FinOps-minded engineers who want last month’s bill as data they can sort, diff or paste into a spreadsheet, instead of reading it off the Billing console. You’ll get a TypeScript script for the AWS SDK for JavaScript v3 that pulls the AWS cost by service for last month, ranks the services, shows each one’s share of the total and can export CSV.

It belongs to our set of runnable AWS SDK v3 practical examples, and it’s the first of five cost scripts published together. Once you have the breakdown, the next questions are usually “which service costs the most over time?” and “why did it change?”, which the companion examples below answer.

What does this script do?

  1. Works out last month’s datesCost Explorer takes dates as YYYY-MM-DD strings in UTC, and the End date is exclusive. For a run in October, the period is 2026-09-01 to 2026-10-01, which covers every day of September.
  2. Calls GetCostAndUsage grouped by serviceOne request with Granularity: "MONTHLY" and GroupBy: [{ Type: "DIMENSION", Key: "SERVICE" }]. If the response carries a NextPageToken, the script requests the next page until there isn’t one.
  3. Sums, sorts and printsWith GroupBy set, the per-period Total field comes back empty, so the script adds the groups itself, sorts them by cost and prints each service’s share of the bill.
  4. Reports its own costIt counts the API requests it made and prints the charge, so there are no surprises when it runs on a schedule.

The request and response fields are documented in the AWS GetCostAndUsage API reference, including the full list of dimensions you can group by.

Prerequisites

  • Node.js 18 or later with npm, and tsx to run TypeScript directly.
  • The @aws-sdk/client-cost-explorer package.
  • Cost Explorer enabled for the account. It’s enabled from the Billing and Cost Management console, and data can take up to 24 hours to appear after you turn it on for the first time.
  • An AWS profile with the ce:GetCostAndUsage permission. If the profile is in a member account of AWS Organizations, it sees that account’s costs; the management account sees costs for the whole organization.

Which IAM permissions does it need?

Only one action. The ReadOnlyAccess managed policy that many teams use for exploration doesn’t include Cost Explorer, so add this to the profile you run the script with:

cost-by-service-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ReadCostAndUsageByService",
      "Effect": "Allow",
      "Action": "ce:GetCostAndUsage",
      "Resource": "*"
    }
  ]
}

If you extend the script with other Cost Explorer calls, run it through the IAM policy generator for TypeScript AWS code to draft the new actions, then check the draft against the checklist to review a generated IAM policy for least privilege before attaching it.

The full script: AWS cost by service for last month with SDK v3

cost-by-service-last-month.ts

// cost-by-service-last-month.ts
// Prints last calendar month's AWS cost broken down by service, using the
// Cost Explorer GetCostAndUsage API. Each API request costs $0.01.
// Usage: npx tsx cost-by-service-last-month.ts [--metric UnblendedCost] [--csv]
import {
  CostExplorerClient,
  GetCostAndUsageCommand,
  type GetCostAndUsageCommandOutput,
} from "@aws-sdk/client-cost-explorer";

const METRICS = ["UnblendedCost", "AmortizedCost", "NetUnblendedCost", "BlendedCost"];

function argValue(flag: string): string | undefined {
  const i = process.argv.indexOf(flag);
  return i === -1 ? undefined : process.argv[i + 1];
}

// Cost Explorer dates are UTC, in YYYY-MM-DD form, and the End date is exclusive.
function lastMonthPeriod(now = new Date()): { Start: string; End: string } {
  const firstOfThisMonth = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
  const firstOfLastMonth = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1));
  const iso = (d: Date) => d.toISOString().slice(0, 10);
  return { Start: iso(firstOfLastMonth), End: iso(firstOfThisMonth) };
}

function csvField(value: string): string {
  return `"${value.split('"').join('""')}"`;
}

async function main(): Promise<void> {
  const metric = argValue("--metric") ?? "UnblendedCost";
  if (!METRICS.includes(metric)) throw new Error(`--metric must be one of ${METRICS.join(", ")}`);
  const asCsv = process.argv.includes("--csv");

  // The Cost Explorer API is served from us-east-1 whatever region your resources use.
  const ce = new CostExplorerClient({ region: "us-east-1" });
  const period = lastMonthPeriod();

  const rows: { service: string; amount: number; unit: string }[] = [];
  let estimated = false;
  let nextPageToken: string | undefined;
  let requests = 0;
  do {
    const res: GetCostAndUsageCommandOutput = await ce.send(
      new GetCostAndUsageCommand({
        TimePeriod: period,
        Granularity: "MONTHLY",
        Metrics: [metric],
        GroupBy: [{ Type: "DIMENSION", Key: "SERVICE" }],
        NextPageToken: nextPageToken,
      }),
    );
    requests++;
    for (const result of res.ResultsByTime ?? []) {
      if (result.Estimated) estimated = true;
      for (const group of result.Groups ?? []) {
        const value = group.Metrics?.[metric];
        rows.push({
          service: group.Keys?.[0] ?? "(unknown)",
          amount: Number(value?.Amount ?? 0),
          unit: value?.Unit ?? "USD",
        });
      }
    }
    nextPageToken = res.NextPageToken;
  } while (nextPageToken);

  rows.sort((a, b) => b.amount - a.amount);
  const total = rows.reduce((sum, r) => sum + r.amount, 0);
  const unit = rows[0]?.unit ?? "USD";

  if (asCsv) {
    console.log("service,amount,unit");
    for (const r of rows) console.log(`${csvField(r.service)},${r.amount.toFixed(2)},${r.unit}`);
    return;
  }

  console.log(`${metric} by service, ${period.Start} to ${period.End} (end date exclusive)`);
  console.table(
    rows
      .filter((r) => Math.abs(r.amount) >= 0.005)
      .map((r) => ({
        Service: r.service,
        Cost: r.amount.toFixed(2),
        Share: total > 0 ? `${((r.amount / total) * 100).toFixed(1)}%` : "-",
      })),
  );
  console.log(`Total: ${total.toFixed(2)} ${unit} across ${rows.length} services`);
  if (estimated) console.log("Note: AWS marks part of this period as estimated; figures can still change.");
  console.log(`Cost Explorer API requests made: ${requests} (about $${(requests * 0.01).toFixed(2)})`);
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});

The client is pinned to us-east-1 because the Cost Explorer API endpoint lives there, whatever regions your resources run in. The --metric flag switches between cost views:

Metric What it shows Use it when
UnblendedCost Charges as they were billed, on the day they were incurred You want the figures to match the invoice (the default)
AmortizedCost Upfront Reserved Instance and Savings Plans fees spread across the term You buy commitments and want a steady monthly view
NetUnblendedCost Unblended cost after discounts You have negotiated or volume discounts
BlendedCost Averaged rates across an organization’s accounts Rarely; mostly for consolidated billing reports

How do you run it?

Terminal

npm install @aws-sdk/client-cost-explorer
npm install --save-dev tsx typescript

# Last month's cost by service as a table
AWS_PROFILE=billing-readonly npx tsx cost-by-service-last-month.ts

# Amortized view, exported to CSV
AWS_PROFILE=billing-readonly npx tsx cost-by-service-last-month.ts --metric AmortizedCost --csv > last-month.csv

Sample output

Output

UnblendedCost by service, 2026-08-01 to 2026-09-01 (end date exclusive)
┌─────────┬──────────────────────────────────────────┬──────────┬─────────┐
│ (index) │ Service                                  │ Cost     │ Share   │
├─────────┼──────────────────────────────────────────┼──────────┼─────────┤
│ 0       │ 'Amazon Elastic Compute Cloud - Compute' │ '612.48' │ '41.2%' │
│ 1       │ 'Amazon Relational Database Service'     │ '318.90' │ '21.5%' │
│ 2       │ 'Tax'                                    │ '190.20' │ '12.8%' │
│ 3       │ 'EC2 - Other'                            │ '184.12' │ '12.4%' │
│ 4       │ 'Amazon Simple Storage Service'          │ '96.37'  │ '6.5%'  │
│ 5       │ 'AmazonCloudWatch'                       │ '71.05'  │ '4.8%'  │
│ 6       │ 'AWS Lambda'                             │ '12.44'  │ '0.8%'  │
└─────────┴──────────────────────────────────────────┴──────────┴─────────┘
Total: 1485.56 USD across 14 services
Cost Explorer API requests made: 1 (about $0.01)

Figures are illustrative. Services below half a cent are counted in the total but hidden from the table, which is why the service count is higher than the number of rows. Tax appears as its own line in the SERVICE dimension.

How do you read the service names?

Cost Explorer’s SERVICE values aren’t always the names you’d expect. Amazon Elastic Compute Cloud - Compute is instance hours only. EC2 - Other collects EBS volumes and snapshots, NAT gateway hours and some data transfer, so a large figure there is often storage nobody is using. The example to find and tag unattached EBS volumes is a good next step when that line is high. If NAT gateway hours dominate, find idle NAT gateways that carry almost no traffic.

CloudWatch is reported as AmazonCloudWatch, and it includes CloudWatch Logs. If it’s larger than you expect, the companion script to get this month’s CloudWatch cost by usage type splits it into log ingestion, storage, metrics and alarms. When storage is the part that keeps growing, set a retention period on CloudWatch log groups that never expire.

DynamoDB is another line worth a second look when tables use provisioned capacity: the example to find overprovisioned DynamoDB read and write capacity compares each table with last month’s usage. If you use AmortizedCost because you buy Reserved Instances, a companion script can find EC2 Reserved Instances about to expire before their coverage ends.

Troubleshooting

  • AccessDeniedException on GetCostAndUsage. The profile lacks ce:GetCostAndUsage, or a service control policy denies it. The IAM access denied guide linked below shows how to tell which policy is blocking the call.
  • DataUnavailableException. Cost Explorer isn’t enabled yet, or it was enabled less than a day ago.
  • The total doesn’t match the invoice exactly. Early in the month, last month’s figures can still be marked Estimated; the script prints a note when that happens. Credits and refunds also appear as negative amounts under the service they apply to.
  • Only one account’s costs appear. In an organization, member accounts only see their own spend. Run the script with a profile in the management account to see everything, and add a second GroupBy on LINKED_ACCOUNT to split it by account (the API allows up to two groupings). Grouping by a cost allocation tag works the same way, and the script to find untagged AWS resources shows what lands in the empty group.
  • Dates are off by a day. Cost Explorer works in UTC. The script builds dates with Date.UTC so local time zones don’t shift the month.

Ask ChatWithCloud instead

If you’d rather not maintain a script, run npx chatwithcloud and ask “What did each AWS service cost last month?” ChatWithCloud writes AWS SDK for JavaScript v2 code, runs it on your machine with your AWS profile, sends the JSON result to the model and explains it. Follow-ups like “why is EC2 – Other so high?” work in the same session; the walkthrough to ask AI why your AWS bill increased shows a full cost conversation. The profile needs ce: permissions, and because changes run without a confirmation step, connect ChatWithCloud with a read-only AWS profile before exploring. Each question that calls Cost Explorer is billed by AWS at the same $0.01 per request. How ChatWithCloud runs generated SDK code locally covers the loop, and the ChatWithCloud security model explains what leaves your machine.

Frequently asked questions

How much does the Cost Explorer API cost?

AWS charges $0.01 per API request (as of September 2026). This script normally makes one request per run, so running it daily costs about $0.30 a month. Viewing Cost Explorer in the console is free.

How do I get AWS cost by service for a specific month?

Change the TimePeriod: set Start to the first day of that month and End to the first day of the next month, because the end date is exclusive. Cost Explorer keeps 13 months of history by default.

Can I break the cost down by service and account at the same time?

Yes. GroupBy accepts up to two entries, so add { Type: "DIMENSION", Key: "LINKED_ACCOUNT" } next to SERVICE. Each group’s Keys array then holds both values.

Why is my Cost Explorer total different from the bill?

The usual causes are estimated figures early in the month, the metric you chose (amortized versus unblended), and credits or refunds. Use UnblendedCost once the month is finalized for the closest match to the invoice.

Related guides

Ask your AWS account in plain English

Your first 15 runs are free, with no OpenAI key needed.

npx chatwithcloud