Your idle Databricks serving endpoints bill you to do nothing.
A serving endpoint doesn't bill for the tokens it generates — it bills for staying ready to generate them. Three questions, four plain SELECTs over Databricks system tables, and the console runs none of them for you.
Model serving is priced differently, and the console's chart doesn't say so. It bills for the seconds a model spent capable of answering.
A cluster you forget to stop burns compute because something is still running on it. A serving endpoint you forget about burns DBUs because Databricks kept a model warm, loaded, and reachable — whether or not a single request arrived. That is the entire failure mode of this domain: serving bills for readiness, not use.
The practical consequence is a dormant endpoint reads as a pure drip, not a spike. It never shows up on a cost graph built to flag anomalies, because there is nothing anomalous about a flat, permanent line — that's exactly what "permanent" looks like on a chart. And because it compounds quietly, the account it hurts most is rarely the one you'd guess: non-prod workspaces spin up serving endpoints for a demo, a proof of concept, a fine-tune nobody productionised, and dev idle usually outlasts prod idle simply because nobody gets paged when a dev endpoint sits quiet. The questions below find it; the plain SELECTs that follow answer them.
Every figure a reader produces here is read from Databricks Unity Catalog system.billing.usage and system.serving.* by a plain SELECT — no application, no dashboard, no live connection. Dollars are priced from system.billing.list_prices (effective_list) — DBU-only, pre-discount, est · at list — and are directional, not a billed figure. Empty results are treated as not assessed, never as $0.
Availability varies by account, and access is admin-gated. Not every system.* table exists on every account — several are Public or Private Preview, feature-gated, or must be enabled per-metastore by an admin, and the serving usage tables in particular are readable by account admins only. A table that isn't enabled or a role that can't see it returns TABLE_OR_VIEW_NOT_FOUND, which reads as not enabled, not zero.
A serving endpoint doesn't bill for the answers it gives. It bills for the seconds it spent capable of answering.
"Which endpoints are dormant but still costing?" The bill has a floor. Traffic is the only thing that says whether anyone's paying for a reason.
Start with the floor. system.billing.usage carries every dollar Databricks charged for serving — filter to the Model Serving SKUs (PREMIUM_SERVERLESS_REAL_TIME_INFERENCE and its STANDARD/ENTERPRISE tier siblings) and usage_metadata.endpoint_name gives you the bill, one row per endpoint per day, before a single request is considered.
SELECT usage_metadata.endpoint_name AS endpoint_name, SUM(usage_quantity) AS net_dbus, COUNT(DISTINCT usage_date) AS billed_days FROM system.billing.usage WHERE sku_name LIKE '%SERVERLESS_REAL_TIME_INFERENCE%' AND usage_date >= dateadd(day, -30, current_date()) AND usage_date < current_date() GROUP BY 1 ORDER BY net_dbus DESC;
billed_days is the tell: an endpoint billed on all thirty days is provisioned to be ready every day of the month, independent of whether it served one request or none. That's the readiness charge, and it's the number the console's per-SKU chart folds into a total and never breaks out by name.
One netting note matters here: system.billing.usage carries corrections as their own rows — record_type of RETRACTION or RESTATEMENT alongside ORIGINAL — with retracted quantities stored negative, so a plain SUM across all three nets to the true figure automatically. Filtering to ORIGINAL only would inflate every number below it.
Cost alone can't say which of those rows are dormant — for that you need traffic, and traffic lives in a different schema. system.serving.endpoint_usage logs one row per request, no pre-aggregated count column, so a request total is a plain COUNT(*). Joining it to system.serving.served_entities via served_entity_id recovers the human-readable endpoint_name — but served_entities is change-history, one row per config edit, so it needs collapsing to its latest row per entity first or the join fans out and inflates every count downstream.
WITH spend AS (
SELECT
usage_metadata.endpoint_name AS endpoint_name,
SUM(usage_quantity) AS net_dbus
FROM system.billing.usage
WHERE sku_name LIKE '%SERVERLESS_REAL_TIME_INFERENCE%'
AND usage_date >= dateadd(day, -30, current_date())
AND usage_date < current_date()
GROUP BY 1
),
entities AS (
-- served_entities is change-history; MAX_BY collapses it to the latest row per entity.
SELECT
served_entity_id,
MAX_BY(endpoint_name, change_time) AS endpoint_name
FROM system.serving.served_entities
GROUP BY served_entity_id
),
traffic AS (
SELECT
ent.endpoint_name,
COUNT(*) AS request_count,
MAX(eu.request_time) AS last_request
FROM system.serving.endpoint_usage eu
JOIN entities ent
ON eu.served_entity_id = ent.served_entity_id
WHERE eu.request_time >= dateadd(day, -30, current_date())
GROUP BY 1
)
SELECT
spend.endpoint_name,
spend.net_dbus,
COALESCE(traffic.request_count, 0) AS request_count,
traffic.last_request
FROM spend
LEFT JOIN traffic ON traffic.endpoint_name = spend.endpoint_name
WHERE COALESCE(traffic.request_count, 0) < 10
ORDER BY spend.net_dbus DESC;
Read it like this: endpoints with real DBU spend and fewer than ten requests in thirty days — most accounts get back a short list, and it is rarely the endpoint anyone remembers building. Note the join key: endpoint_usage carries served_entity_id, not endpoint_id or a name — skip the hop through served_entities and every join comes back empty, which reads as "no dormant endpoints" when it actually means "wrong key."
"What is per-endpoint traffic vs. cost?" Dormant is binary. Most endpoints aren't dormant — they're just badly used, and that's a ranking, not a flag.
Binary dormant-or-not misses the more common case: an endpoint that gets some traffic but far less than its provisioned floor justifies. Drop the filter that cut off near-zero and rank the whole fleet by DBUs spent per request instead — the same two joins, read as a spectrum rather than a cutoff.
WITH spend AS (
SELECT
usage_metadata.endpoint_name AS endpoint_name,
SUM(usage_quantity) AS net_dbus
FROM system.billing.usage
WHERE sku_name LIKE '%SERVERLESS_REAL_TIME_INFERENCE%'
AND usage_date >= dateadd(day, -30, current_date())
AND usage_date < current_date()
GROUP BY 1
),
entities AS (
SELECT
served_entity_id,
MAX_BY(endpoint_name, change_time) AS endpoint_name
FROM system.serving.served_entities
GROUP BY served_entity_id
),
traffic AS (
SELECT
ent.endpoint_name,
COUNT(*) AS request_count
FROM system.serving.endpoint_usage eu
JOIN entities ent
ON eu.served_entity_id = ent.served_entity_id
WHERE eu.request_time >= dateadd(day, -30, current_date())
GROUP BY 1
)
SELECT
spend.endpoint_name,
spend.net_dbus,
COALESCE(traffic.request_count, 0) AS request_count,
ROUND(spend.net_dbus / NULLIF(traffic.request_count, 0), 4) AS dbus_per_request
FROM spend
LEFT JOIN traffic ON traffic.endpoint_name = spend.endpoint_name
ORDER BY dbus_per_request DESC NULLS FIRST;
NULLIF stops a divide-by-zero for endpoints with cost and no requests at all — those sort to the top with a NULL ratio, the same dormant signal from the last section, now sitting at the extreme end of the scale. Below them: endpoints that do get called, ranked by how expensive each call actually was in DBU terms. Read it like this: one row per endpoint, dbus_per_request descending — the top is where a smaller workload size or a scale-to-zero setting would pay for itself; the bottom is the endpoint doing real, dense work and probably under-provisioned if anything.
"Any AI Gateway abuse signals?" A leaked token doesn't look like a cost line. It looks like one requester suddenly calling ten times as often.
requester on system.serving.endpoint_usage is the principal — user or service principal — behind every request. A sudden jump in one principal's daily volume is the signature of a leaked token, a runaway retry loop, or a forgotten script pointed at a pay-per-token foundation-model endpoint. Compare each requester's daily count against their own trailing average rather than a fleet-wide threshold, so a normally quiet service account tripping to a thousand calls flags even if it's still small next to your busiest endpoint.
WITH daily AS (
SELECT
requester,
date_trunc('day', request_time) AS day,
COUNT(*) AS requests
FROM system.serving.endpoint_usage
WHERE request_time >= dateadd(day, -14, current_date())
GROUP BY 1, 2
),
baseline AS (
SELECT
requester,
day,
requests,
AVG(requests) OVER (
PARTITION BY requester
ORDER BY day
ROWS BETWEEN 7 PRECEDING AND 1 PRECEDING
) AS trailing_avg
FROM daily
)
SELECT
requester,
day,
requests,
ROUND(trailing_avg, 1) AS trailing_avg_7d,
ROUND(requests / NULLIF(trailing_avg, 0), 1) AS spike_ratio
FROM baseline
WHERE trailing_avg IS NOT NULL
AND requests > 5 * trailing_avg
ORDER BY spike_ratio DESC;
Read it like this: requester, day, and how many multiples of their own baseline that day ran — a five-times day next to a name that normally runs single digits is worth a Slack message before it's worth a root-cause. Access to this table is account-admin-only by design, so an empty result may just mean the query ran without admin access — check who's running it rather than concluding nobody's abusing anything.
The receipt. Four SELECTs, and the number was never the point.
Serving is the domain where the readiness-versus-use gap is easiest to lose: no cluster runs, no job fails, nothing looks broken — an endpoint just sits warm, billing a straight line every day, for as long as nobody asks whether anyone's calling it. Four plain SELECTs surface it: the daily floor, the dormant join, the per-endpoint ranking, and the principal that just changed behavior.
These four are one domain out of seven. The full set — the query library — is ninety-six SELECTs across cost, performance, compute, jobs, serving, storage, and governance, each traced to the row that produced it, open on GitHub. The Databricks audit reference walks the same domains in tutorial form, if a SELECT alone isn't enough context.
Every query reads Databricks Unity Catalog system.* tables via plain SELECT; dollar estimates are est · at list, DBU-only, directional. Empty is treated as not assessed. — Crosshire
- Databricks query library: the receipts, unbundled — the full set: all 96 SELECTs across seven domains, every query in this post included.
- crosshire-audit-databricks-admin — the source on GitHub: self-contained .sql files you run in a Databricks SQL editor.
- Jobs on the expensive clock — the same receipts thesis applied to the Jobs & Workflows domain.
- The security findings your console won't surface — the Governance & Security domain: unmasked columns, privilege escalation, PII lineage.
- What your tables actually weigh — the Storage domain: file counts, small-file drag, and orphaned data.
- Crosshire Audit: your warehouse, with receipts — the Snowflake sibling: the same receipts thesis, one export, one dashboard.
- Databricks audit reference — the tutorial walkthrough this library's queries are drawn from, on Crosshire Learn.
- AI Gateway usage tracking tables — Databricks' own schema reference for system.serving.endpoint_usage and served_entities.
- Crosshire consulting — data platform audit engagements with the same rigour as this library.