◉CLAUDE LABJP
●2.1.312 — Fixes Claude overwriting very large CLAUDE.md files and skills going missing after a Bash cd●API — The Messages API maximum request size grew from 32 MB to 128 MB (10/28)●SONNET 4.5 — Retires on the Claude API on 11/30, 30 days left. Migrate to Sonnet 5.5●WINDOWS — Reports say hooks break silently only on Japanese-locale Windows; check the character encoding first●FAST — Opus 4.8 fast mode is deprecated and will be removed on 11/30. Migrate to Opus 5.5●NEW — I Put a Ten-Line Promise File at the To…●2.1.312 — Fixes Claude overwriting very large CLAUDE.md files and skills going missing after a Bash cd●API — The Messages API maximum request size grew from 32 MB to 128 MB (10/28)●SONNET 4.5 — Retires on the Claude API on 11/30, 30 days left. Migrate to Sonnet 5.5●WINDOWS — Reports say hooks break silently only on Japanese-locale Windows; check the character encoding first●FAST — Opus 4.8 fast mode is deprecated and will be removed on 11/30. Migrate to Opus 5.5●NEW — I Put a Ten-Line Promise File at the To…
Articles/API & SDK
⬡ API & SDK/2026-10-31Intermediate

Days Vanished From My Daily Table After Moving to the Usage and Cost Analytics API — Why Empty Days Don't Come Back, and How to Zero-Fill

The Usage and Cost Analytics API (beta since 10/29) returns no rows for days with no usage. Here is why a drop-in swap from the legacy report leaves gaps, the order I'd check things in, and a small Python zero-fill, tested against mock data.

Claude API140Usage and Cost Analytics APIAdmin APIcost trackingmigration12

On a Saturday afternoon I was reading the legacy Usage and Cost API migration table from top to bottom, and one row made me stop. The legacy data is a list of time buckets, empty ones included. The new Analytics API returns a list of rows, and only for combinations that actually have usage.

If you like to look at a daily usage table, you may already see the problem. Days you didn't use anything simply vanish from it.

One caveat first. My own account is an individual one, and the Admin API isn't available to individual accounts (the docs say so plainly). So everything below was checked against a mock shaped like the official sample response, not against a live organization. Please read it with that in mind.

Three reasons days look missing

Here are the differences from the migration table that matter for a daily table.

ItemLegacy Usage and Cost APIUsage and Cost Analytics API
Empty days / combinationsEmpty buckets are returnedNo row is created
Daily splitbucket_width (default 1d)time_bucket_width (no default)
What limit countsTime bucketsRows (1–1,000, default 20)
Data freshnessTypically within 5 minutesTypically within 4 hours, up to 24 hours
Range per requestNo stated capUp to 31 days; 24 hours for 1-minute buckets

So if you call it the way you called the old one, at least three things can leave you with a table that has too few days:

  1. Days with no usage produce no row at all.
  2. Without time_bucket_width, you get a single row that totals the whole range.
  3. Without raising limit, a page stops at 20 rows and you never read the rest.

Working backwards from what you see

This is the order I'd check in, because each symptom points to a different first suspect.

What you seeSuspect firstHow to check
Only one row comes backMissing time_bucket_widthIs time_bucket_width=1d in the request?
4–5 days instead of 7Days with no usageCheck your own logs for those days
Row count jumps when you add modelsOne row per combinationCompare group_by[] count to row count
Nothing after a certain dayPaging, or data not refreshed yethas_more and data_refreshed_at

The last row is the tricky one, because a gap and a freshness delay look identical. Usage after the data_refreshed_at in the response is either missing or partial. A thin most-recent day is not a bug; it is how the API works.

Rebuilding the daily table with zero-fill

The official migration notes themselves say to fill missing days with zeros. I turned that into a small function that folds rows by model and lays out every day in the range.

from datetime import datetime, timedelta, timezone
from collections import defaultdict
 
 
def fill_daily(rows, start, end, key="model"):
    """Zero-fill missing days in Analytics API usage rows (time_bucket_width=1d)."""
    days = []
    d = start
    while d < end:
        days.append(d)
        d += timedelta(days=1)
 
    table = defaultdict(lambda: {day: 0 for day in days})
    for row in rows:
        day = datetime.fromisoformat(row["starting_at"].replace("Z", "+00:00"))
        name = row["group"][key] or "(none)"
        table[name][day] += row["usage"]["total_tokens"]
 
    return {k: [(day, v[day]) for day in days] for k, v in table.items()}

The calling side follows the official migration example: pass time_bucket_width explicitly, raise limit to 1,000, and let the SDK fetch the remaining pages as you iterate.

import anthropic
 
client = anthropic.Anthropic()  # reads an admin-capable key from the environment
rows = client.beta.organization.analytics.usage.list(
    starting_at="2026-09-01T00:00:00Z",
    ending_at="2026-09-08T00:00:00Z",
    time_bucket_width="1d",
    group_by=["model"],
    limit=1000,
)
as_dicts = [r.model_dump() for r in rows]

I can't run that against a real key, so I ran fill_daily on a mock: rows only for September 1 and 3, laid out across the seven days from the 1st to the 7th.

claude-sonnet-5 [5590800, 0, 120000, 0, 0, 0, 0]
(none) [0, 0, 0, 0, 0, 0, 0]

The gap on the 2nd became a 0, and the row with an empty model landed under (none). A null model comes from usage such as code execution, as the docs explain. If you leave it as None and use it as a dictionary key, sorting tends to break later, so I name it up front.

Three places to look when the numbers still disagree

If zero-fill doesn't settle it, check these three, in order.

  • Keep ending_at at or before a data_refreshed_at you've already seen. When the same range gives different numbers on different days, the usual cause is that you included a day that was still refreshing.
  • Trust final totals only after a month has been closed for 7+ days. The docs say values can be revised until about 7 days after the end of the calendar month, with October 1 changing until about November 7 as the example.
  • Keep each request within 31 days. A longer period can't be fetched in one call, so plan on stitching two requests together for tables that cross a month boundary.

If your numbers disagreed with the legacy API or the Console, start with these three. The legacy API refreshed in about 5 minutes, so "it matched yesterday" is probably just the new API's slower refresh showing through.

Keep the old API running and compare side by side

One more thing I wanted to confirm after writing all this. The docs describe the legacy API as supported, with the new one recommended for new integrations. There's no need to rush the swap.

Put the new table next to the old one, and switch only after they agree. That's the line I drew. Lay the zero-filled table for the same period next to your legacy daily report, ignore differences in the most recent week, and compare only a month that has already closed. Once the difference is gone, switch the source.

If you split monthly spend by workspace, the thinking in my earlier note "Assigning and narrowing spend per workspace" carries over as is. Only the entry-point endpoint changes; the way you distribute the numbers doesn't.

A good first step is to search your current aggregation script for bucket_width once, and write down on a single line each where time_bucket_width and limit will need to go in the new API.

Share

Thank You for Reading

Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

⬡ API & SDK2026-10-27
Three Places Python SDK 2.0.0 Breaks Your Code — A Checking Order Built on a Mocked Transport
Upgrading to Python SDK 2.0.0 changes behavior in three places: the temperature argument, the auth header, and double credentials. Here are the outputs I reproduced on a mocked transport, the fix for proxy setups, and the order I check in.
⬡ API & SDK2026-10-20
Moving Structured Outputs to output_config.format — What to Check Before You Drop the Beta Header
A checklist for moving structured outputs from output_format and the beta header to output_config.format, drawn from the official docs: the Python SDK TypeError, prompt-cache invalidation, schema limits, and the cases where output won't match your schema.
⬡ API & SDK2026-08-30
Read the Catalog Your CLI Already Ships Before Bulk-Replacing Model IDs
A newer generation makes older model IDs look stale. Here is how to pull the catalog embedded in your installed binary, sort every reference into matched, date-mismatched, and malformed, and stop the replacement that would break working IDs.