Crosshire
← Handbook
FoundationClustering · optimization · compute · tags

04Storage & cost

This route decides how tables are laid out, who runs maintenance, which compute a workload may use, and how a euro of spend is traced to a team. Three of those are reversible. Tagging is not: usage rows are immutable, so a taxonomy agreed in month four leaves months one to three permanently unattributable.

Stand 29.07.2026

Table layout

Two questions usually answered by copying a 2023 blog post: how the data is physically arranged, and who compacts it. Both answers have changed.

#Liquid clustering, not partitioning

What
Liquid clustering replaces both partitioning and ZORDER, and Databricks recommends it over Z-ordering for all new tables. Most Delta tables under 100 TB need no partitioning at all — which for a distributor selling into four countries means nothing in this warehouse is partitioned.
sql
gold.fct_order_line — the only layout statement it needs
CREATE TABLE gold.fct_order_line (
  order_line_sk   STRING         COMMENT 'Surrogate key: sha2 of order_id + line_number',
  order_id        STRING,
  line_number     INT,
  customer_sk     STRING         COMMENT '-> dim_customer (informational only)',
  product_sk      STRING,
  date_sk         DATE,
  region_sk       STRING,
  order_ts        TIMESTAMP,
  quantity        DECIMAL(12,3),
  unit_price      DECIMAL(18,2),
  currency        STRING         COMMENT 'EUR | CHF',
  fx_rate         DECIMAL(18,6),
  amount_eur      DECIMAL(18,2)  COMMENT 'ROUND(quantity * unit_price * fx_rate, 2)',
  status          STRING         COMMENT 'OPEN | SHIPPED | CANCELLED',
  _tech_loaded_at TIMESTAMP
)
CLUSTER BY (customer_sk, order_ts)
COMMENT 'One row per order line. Owner: dwh-sales. Cadence: hourly.';
The keys are not a guess: every dashboard query on this fact filters a date range and drills into a customer, so customer_sk and order_ts are what appears in the WHERE clause. Four keys is the maximum; two is usually right, because the keys share one ordering — every key you add spends part of the file layout on a column that appears in fewer queries, and dilutes the one that appears in all of them.
sql
Changing the layout of a table that already exists
-- unpartitioned table -> clustered
ALTER TABLE gold.dim_customer CLUSTER BY (customer_id);

-- ALREADY PARTITIONED -> clustered (this is the migration path)
ALTER TABLE gold.fct_order_line
  REPLACE PARTITIONED BY WITH CLUSTER BY (customer_sk, order_ts);

-- hand the key choice to predictive optimization
ALTER TABLE gold.agg_revenue_month CLUSTER BY AUTO;

-- turn it off entirely
ALTER TABLE gold.agg_revenue_month CLUSTER BY NONE;

-- none of the above rewrites existing data. THIS does, once:
OPTIMIZE gold.fct_order_line FULL;
Why here
PARTITIONED BY (year, month, day) is the default reflex, and here it is a guess made before anyone had queried anything. A day of order lines across four countries is a few megabytes, so every partition is one small file and a year is over a thousand files containing almost nothing — and any query filtering on customer_sk, which is most of them, opens the lot. You notice it as query times rising with the age of the table while the data volume stays trivial. By then the partition column is in the physical path of every file.
Doing it
  • Create every table with CLUSTER BY. No PARTITIONED BY anywhere in the repository — a grep in CI is the whole enforcement.
  • Choose keys from observed filter columns, not from the calendar. system.query.history tells you what the last month of dashboard queries actually filtered on.
  • Use CLUSTER BY AUTO where the access pattern is unknown or still moving; declared keys act as the starting hint.
  • After a key change run OPTIMIZE … FULL exactly once, off-peak, then never by hand again.
Embedding it
  • Have them clone gold.fct_order_line into a partitioned copy and a clustered copy, run the same dashboard query against both, and read numFiles from DESCRIBE DETAIL. The number ends the argument without you in it.
  • When someone proposes PARTITIONED BY, do not reject it — ask for the expected row count per partition. They usually withdraw it themselves, and now they know why.
  • Make the CI grep that rejects PARTITIONED BY the team's ten-line job, not yours. A standard without a check is a preference.
Defaults
  • CLUSTER BY on every new table; PARTITIONED BY on none.
  • Two keys, four maximum, taken from real filter columns.
  • CLUSTER BY AUTO and OPTIMIZE … FULL each have a runtime floor (DBR 15.4 LTS and 16.4 LTS, Stand 29.07.2026). Read it off the client's runtime, not off this page.
  • Clustering and ZORDER are mutually exclusive — a table is one or the other.
  • OPTIMIZE … FULL once after a key change; never on a schedule.
Gotchas
  • PARTITIONED BY (year, month, day) produces small files and locks you into a guess made before anyone queried anything. At this warehouse's volumes each partition holds a few megabytes, so a year of data is a thousand tiny files that every customer-filtered query must open. The symptom is latency rising with table age while the data stays small.
  • ALTER TABLE … CLUSTER BY rewrites nothing. Existing files keep their old layout, the table reports clustering keys, and performance does not move — which reads as 'liquid clustering does not work' rather than 'we skipped OPTIMIZE … FULL'.
  • A leftover scheduled OPTIMIZE … ZORDER BY pointed at a now-clustered table. Clustering and Z-ordering are mutually exclusive, so the statement raises DELTA_CLUSTERING_WITH_ZORDER_BY and the nightly job starts failing — and nobody reads the log, or the alert, of a job that has been green for a year. You notice it when someone asks why the table stopped being compacted.
  • Clustering on order_line_sk because it is the primary key. Nothing queries a fact by surrogate key, so the clustering does nothing while the OPTIMIZE cost is entirely real.

#Who runs OPTIMIZE

What
Predictive optimization runs OPTIMIZE, VACUUM and ANALYZE automatically on Unity Catalog managed tables. It is on by default for accounts created on or after 11.11.2024, and the rollout to older accounts was expected to finish in August 2026 — that is, now. So the honest answer to 'do we need a nightly maintenance job?' differs from last quarter's, and the first move is to check rather than assume.
sql
Set it where it is inherited, then watch what it costs
-- catalog level, so schemas and tables created later are covered
ALTER CATALOG dwh_prod ENABLE PREDICTIVE OPTIMIZATION;

-- narrower levers, same grammar: ENABLE | DISABLE | INHERIT
ALTER SCHEMA dwh_prod.gold INHERIT PREDICTIVE OPTIMIZATION;
ALTER TABLE  dwh_prod.gold.fct_order_line INHERIT PREDICTIVE OPTIMIZATION;

-- what it did, and what it cost (usage_unit is ESTIMATED_DBU)
SELECT
  operation_type,                     -- COMPACTION | VACUUM | ANALYZE | CLUSTERING
  COUNT(*)                      AS operations,
  ROUND(SUM(usage_quantity), 2) AS estimated_dbus
FROM system.storage.predictive_optimization_operations_history
WHERE catalog_name = 'dwh_prod'
  AND start_time >= current_date() - INTERVAL 30 DAYS
GROUP BY 1
ORDER BY estimated_dbus DESC;
Why here
Almost every team arrives with a nightly OPTIMIZE + VACUUM notebook copied from a post written before predictive optimization existed. Nothing about it looks wrong — the tables are compacted, the job is green — so nobody revisits it and the bill quietly carries two maintenance systems. The other half of that notebook is worse: a shortened VACUUM retention, added to save storage, which deletes files a concurrent reader is still using and destroys the time travel you were relying on for the incident you have not had yet.
Doing it
  • Before writing any maintenance job, check whether predictive optimization is enabled on the catalog. On an account mid-rollout that check is the difference between necessary work and duplicate spend.
  • Enable it at catalog level on dwh_prod so tables created next year inherit it without anyone remembering.
  • Delete the inherited nightly OPTIMIZE/VACUUM job for every table it covers. Leaving it 'just in case' is the expensive way to do nothing.
  • Keep exactly one manual lever: OPTIMIZE … FULL after a clustering-key change.
Embedding it
  • Ask the team to find every scheduled job whose name contains 'optimize' or 'vacuum' and justify each one. The list is always longer than they expect, and finding it themselves is what makes them delete it.
  • Have them explain what the seven-day VACUUM retention protects, out loud, before they may shorten it.
  • Hand over the operations-history query, not a screenshot of it. A team that can rerun the number owns the number.
Defaults
  • Predictive optimization enabled at catalog level; individual tables INHERIT.
  • No hand-written OPTIMIZE or VACUUM schedules for managed tables.
  • Default VACUUM retention stays at seven days unless a written retention decision says otherwise.
  • Maintenance cost reviewed monthly from the operations-history system table, not estimated.
Gotchas
  • Manual OPTIMIZE running alongside predictive optimization. You pay for both, and the writers contend on the same table version — visible as ConcurrentModificationException retries in a job whose code has not changed in months.
  • VACUUM RETAIN 0 HOURS, or lowering delta.deletedFileRetentionDuration to trim storage. It deletes files a running query is still reading, so a downstream job fails with FileNotFoundException on data that is plainly there, and the time-travel window you would have used to recover from a bad load is gone.
  • Predictive optimization does not cover external tables. If half the warehouse is external, half of it is silently unmaintained, and the divergence surfaces months later as one dashboard getting slower for no visible reason.
  • Assuming it is on because the docs say it is on by default. That default applies to accounts created on or after 11.11.2024; an older account mid-rollout is exactly where the wrong assumption buys a duplicate maintenance bill.

Storage ownership and compute

Who owns the files, and which machine may read them. Both are one-line defaults that are hard to change once a hundred tables exist.

#Managed by default

What
A managed table lets Unity Catalog own the storage location and the file lifecycle. An external table means you own a path and UC only records that a table lives there. Everything in dwh_dev, dwh_test and dwh_prod is managed, with two named exceptions.
sql
The difference is one clause
-- managed: no LOCATION. The default for every table in the warehouse.
CREATE TABLE gold.dim_product (
  product_sk STRING,
  product_id STRING
)
CLUSTER BY (product_id);

-- external: you own the path, and everything that follows from that.
-- The webshop landing zone is the ONE place this warehouse does it — an
-- external process drops JSON here and Autoloader reads it from the Volume.
CREATE EXTERNAL VOLUME bronze.landing_shop
LOCATION 'abfss://landing@example.dfs.core.windows.net/shop_order/'
COMMENT 'Webshop JSON drop. Written by the shop, never by Databricks.';

-- bronze.shop_order itself is still a MANAGED table. The path is external;
-- the table is not. Those are two decisions and only the first is forced.
What the choice actually decides
ManagedExternal
Predictive optimizationyesno — never
DROP TABLEdeletes the data; UNDROP within 7 daysmetadata only; files remain
Storage locationUC chooses; you never see a pathyour path, your lifecycle, your problem
Justified whenalways, unless the row below appliesanother system writes the files, or a non-Databricks engine reads them in place
The two exceptions are the webshop landing zone — an external Volume the shop writes JSON into, which Autoloader then reads into a managed bronze.shop_order — and any dataset a non-Databricks engine must read in place. Neither is a gold table, and the first one is an external location, not an external table: the files are outside, the table is not.
Why here
Teams reach for external tables as a hedge: if we ever leave Databricks, at least we own the files. The hedge is worth little — Delta is open and readable elsewhere either way — and the price is paid daily, because predictive optimization does not run on external tables at all. Insurance against an exit that probably never happens, bought with permanently manual maintenance on the tables that need it most.
Doing it
  • Make managed the default in the DDL templates, so external requires someone to type a LOCATION and defend it in review.
  • Limit who may create external locations and storage credentials — that permission is the real control, not the CREATE TABLE statement.
  • Where external is genuinely needed, confine it to bronze landing zones. If an external Delta table is unavoidable, write the OPTIMIZE and VACUUM job yourself — predictive optimization will never run for it.
  • Record each exception with a reason and an expiry date, the way the layer contract handles exceptions.
Embedding it
  • Ask the team to list every external table and name what would break if it were managed. Most lists shrink to one or two entries during the conversation.
  • Have them run DROP TABLE and UNDROP TABLE on a throwaway managed table in dev, so the seven-day window is something they have used rather than read about.
  • Let the platform owner, not the consultant, hold the external-location permission from week one — the cleanest piece of ownership to transfer early.
Defaults
  • Managed for everything from silver upward, without exception.
  • External locations and Volumes only for landing zones another system writes, and only in bronze. No external Delta table from silver upward.
  • External-location creation restricted to a named admin group.
  • Every external table carries a written reason and an expiry date.
Gotchas
  • DROP TABLE on a managed table deletes the data. UNDROP TABLE recovers it within seven days; after that it is a cloud-storage restore, if anyone set one up. Teams who learned Delta on external tables generalise the wrong behaviour and find out on a production table.
  • External tables are invisible to predictive optimization. Nothing errors — they simply stop being compacted while their managed neighbours keep improving, so the gap presents as a query regression rather than a maintenance hole.
  • Registering two tables over the same external path. UC will let you, the schemas drift, and two teams argue about which one is 'the real table' while both are writing to it.
  • Choosing external for portability without pricing the maintenance. The exit you are insuring against is hypothetical; the OPTIMIZE and VACUUM jobs you now own are weekly.

#Serverless first, policies second, auto-stop always

What
Three levers, in descending order of money saved per unit of effort: pick serverless, constrain what can be created, and make idle compute stop itself.
Workload to compute
WorkloadComputeWhy
Lakeflow pipelines, scheduled jobsserverlessno idle time between runs; nothing to size
SQL, BI, metric views over gold.mv_salesserverless SQL warehousestarts in seconds, so a short auto-stop is free
Ad-hoc notebook workserverless notebookthe alternative is a cluster somebody forgets
Native libraries serverless cannot takejob compute under a policythe exception — it should need a sentence of justification
yaml
The compute policy definition — fixed, not recommended
# Shown as YAML because that is how it lives in the bundle. The policy
# API takes the same document as a JSON string in `definition`; the
# bundle serialises it for you. 'fixed' means a user cannot override it.
autotermination_minutes:
  type: fixed
  value: 30
data_security_mode:
  type: fixed
  value: SINGLE_USER
node_type_id:
  type: allowlist
  values: [Standard_D4ads_v6, Standard_D8ads_v6]
custom_tags.domain:
  type: fixed
  value: sales
custom_tags.environment:
  type: fixed
  value: prod
SQL warehouse auto-stop, Stand 29.07.2026: serverless defaults to 10 minutes (minimum 5 in the UI, 1 via the API), pro and classic to 45, minimum 10. Defaults move — read them off the client's workspace rather than off this page. The gap exists because a classic warehouse takes minutes to start and a serverless one takes seconds, which is also why 'we use classic to save money' usually costs more.
Why here
The recurring cost of a warehouse this size is not storage — for a four-country distributor's order lines that is a rounding error. It is compute, much of it running while nobody uses it. The canonical incident: someone creates an all-purpose cluster for a five-minute investigation, disables auto-termination because the notebook kept detaching, and goes home on a Friday. Nothing is broken, no alert fires, and it runs until Monday. The fix is a policy attribute, not a reminder.
Doing it
  • Default every new job and pipeline to serverless; make classic compute the exception that carries a written reason.
  • Ship one policy per persona and remove unrestricted cluster-creation permission entirely — a policy that can be bypassed is documentation.
  • Set autotermination_minutes as fixed, not as a default a user can raise when it annoys them.
  • Leave serverless warehouse auto-stop at 10 minutes, and size by concurrency: add clusters before increasing t-shirt size.
Embedding it
  • Give the team the cost query before you give them the policy. A number they pulled themselves argues better than a policy you wrote.
  • Ask them to find the workspace's most expensive idle compute in system.billing.usage and present it. The policy proposal then comes from them.
  • Keep policy changes in the bundle and review them as pull requests. A timeout raised by a click in the admin console has no author and no reason.
Defaults
  • Serverless is the default; classic compute needs a justification in the PR.
  • Cluster creation only through a policy — no unrestricted creation for anyone, including admins doing 'just this once'.
  • autotermination_minutes fixed at 30 for interactive compute.
  • Serverless SQL warehouse auto-stop at 10 minutes; scale out before scaling up.
Gotchas
  • An all-purpose cluster with auto-termination switched off because a notebook kept detaching. It runs all weekend, no alert fires, and the cost appears a month later in a bill nobody reads line by line. Fix it with a fixed policy attribute; a reminder in a wiki has never once worked.
  • A classic SQL warehouse on a 45-minute auto-stop serving a dashboard that refreshes every 30 minutes. It is woken before it can ever time out, so it bills around the clock while the usage graph shows a handful of queries a day.
  • Sizing a warehouse up because one query is slow. T-shirt size buys parallelism for a single query; concurrent users need more clusters. Getting the two backwards doubles the bill and leaves the query slow.
  • Policies apply to new clusters only. Existing ones keep whatever they were created with, so the policy looks effective while the expensive machines predate it.

Cost attribution

Four tag keys and two queries. This sits in the foundation group rather than in run because it is the one thing here that genuinely cannot be fixed later.

#Four tags, applied at creation

What
Four keys, lowercase, fixed value sets. Not five, and no free text — a tag with uncontrolled values is a tag you cannot GROUP BY.
The taxonomy
KeyValuesSet whereAnswers
domainsales | finance | platformcompute policy, fixed per policywhose budget is this
environmentdev | test | prodbundle targetproduction spend or experiment
cost_centreCC-4711 and friendscompute policywhich line in the finance system
ownergroup address, never a personcompute policywho to ask before switching it off
yaml
databricks.yml — tags per target, so environment cannot be wrong
# Declarative Automation Bundles (formerly Asset Bundles; the CLI
# command and the file format are unchanged).
targets:
  prod:
    variables:
      catalog: dwh_prod
    resources:
      jobs:
        silver_customer:
          job_clusters:
            - job_cluster_key: main
              new_cluster:
                custom_tags:
                  domain: sales
                  environment: prod
                  cost_centre: CC-4711
                  owner: dwh-sales@example.com
Why here
The failure mode is precise and it has a date on it. You reach the end of a quarter, finance asks which team spent the Databricks budget, and a third of the usage carries a NULL tag. The only honest answer is that it cannot be determined; fixing the tags today fixes next quarter and leaves this one unattributable permanently. At a DACH client with an internal cost-allocation process that is the meeting where the platform stops being able to justify its own budget, with the consultant who set it up in the room.
Doing it
  • Agree the four keys in week one, before any compute exists. It is a thirty-minute conversation that cannot be had retroactively.
  • Fix the values in the compute policy so a cluster physically cannot be created untagged, rather than documenting the convention.
  • Set environment from the bundle target, so a dev job cannot claim to be prod even if someone copies the YAML.
  • Attach a serverless usage policy to everyone running serverless workloads, then check custom_tags a day later to confirm it landed.
  • Run the untagged-spend query weekly for the first month. It reaches zero, or there is a compute surface nobody has covered.
Embedding it
  • Have the team produce the untagged-spend percentage themselves in week two and present it to their own manager. A number they found is a number they fix.
  • Ask 'which cost_centre?' at every new-job review. When nobody knows, the finding is not the missing tag — it is that the job has no owner.
  • Take the four keys to the client's finance contact and let them reject your value lists. It is their taxonomy; yours only has to be queryable.
Defaults
  • Four keys, fixed value sets, lowercase snake_case.
  • owner is always a group address — a person leaves and the tag becomes a riddle.
  • Tags enforced in policy and bundle, never in a wiki page.
  • Every new compute surface gets a tagging answer before it gets a workload.
Gotchas
  • Tags cannot be backfilled. Usage rows are immutable, so spend that was untagged when you found the gap is unattributable for the rest of time. Discovering it in month four means months one to three are simply gone, and the number you say out loud is a percentage of the client's quarterly platform spend.
  • Serverless compute ignores cluster custom tags. A team with a perfect policy-based taxonomy migrates jobs to serverless and watches attribution collapse without a single tag being changed — and the natural conclusion, that someone broke the tagging, sends them looking in the wrong place.
  • Two different things called tags. Compute custom tags land in system.billing.usage.custom_tags and drive cost; Unity Catalog object tags, including governed tags, sit on catalogs, schemas, tables and columns and drive governance. Neither appears in the other's queries, so 'we tagged everything' can be true and useless at once.
  • Free-text values. Sales, sales and sales-team are three rows in a GROUP BY and one team in reality, and the report understates every one of them.

#The query, and the alarm that is too slow

What
Attribution is one query. It joins usage in DBUs to the price in force at the time — the validity window in the join is the part people leave out.
sql
Cost by domain and environment, last 30 days
SELECT
  u.custom_tags['domain']      AS domain,
  u.custom_tags['environment'] AS environment,
  u.billing_origin_product     AS product,
  ROUND(SUM(u.usage_quantity * p.pricing.effective_list.default), 2) AS list_cost
FROM system.billing.usage u
JOIN system.billing.list_prices p
  ON  u.cloud    = p.cloud
  AND u.sku_name = p.sku_name
  AND u.usage_start_time >= p.price_start_time
  AND (p.price_end_time IS NULL OR u.usage_start_time < p.price_end_time)
WHERE u.usage_date >= current_date() - INTERVAL 30 DAYS
GROUP BY 1, 2, 3
ORDER BY list_cost DESC;
sql
The number that must be zero: unattributable spend this quarter
SELECT
  COALESCE(custom_tags['domain'], '<untagged>') AS domain,
  ROUND(SUM(usage_quantity), 2)                 AS dbus,
  ROUND(100 * SUM(usage_quantity)
        / SUM(SUM(usage_quantity)) OVER (), 1)  AS pct
FROM system.billing.usage
WHERE usage_date >= date_trunc('QUARTER', current_date())
GROUP BY 1
ORDER BY dbus DESC;
Account budgets sit on top: filter by workspace and by custom tag, set a threshold, get an email. They do not stop spend, and the notification carries up to a 24-hour delay between the usage happening and the mail arriving.
Why here
A budget alert is a smoke detector with a day-long fuse: the Friday-afternoon cluster is a finished incident by the time it goes off. That does not make budgets useless — they are the right instrument for a trend across a month — but the fast alarm is a scheduled query with an alert on its result, and you have to build it.
The second reason to own the query rather than a dashboard: it produces list cost, ignoring whatever commitment discount was negotiated. As a relative split between domains it is exactly right; as an absolute figure handed to a controller it is wrong, and being wrong about someone's bill is a credibility event you get to have once.
Doing it
  • Schedule both queries as SQL alerts: the cost breakdown weekly, the untagged percentage daily until it is zero.
  • Create budgets per domain tag rather than one for the account. An account-level budget says spend rose and nothing about where.
  • Set the threshold at 80% of plan. At 100% the alert and the overspend arrive together.
  • Report list cost internally and let procurement apply the discount. Never send the list figure to a client's finance team as their bill.
Embedding it
  • Hand over the query, not the dashboard. A team that can edit the SQL owns the number; a team with a dashboard owns a picture.
  • Have someone from finance in the room for the first monthly review. The taxonomy either survives contact with them or it was never real.
  • Ask the team to predict next month's figure before running the query. The gap measures how well they understand their own platform, and it closes fast once it is public.
Defaults
  • Budgets scoped by domain tag, threshold at 80%, alerts routed to a rota.
  • A daily scheduled query as the fast alarm; the budget as the slow trend.
  • list_prices joined on the price validity window, always.
  • Internal reporting in list cost, clearly labelled as list cost.
Gotchas
  • Budget alerts do not stop spend and can lag usage by up to 24 hours. A cluster left running on Friday afternoon costs the whole weekend before the mail is sent, so treating the budget as your incident alarm means every cost incident runs to completion.
  • Joining usage to list_prices on sku_name alone. Prices change, so without the price_start_time / price_end_time window you multiply last year's usage by today's price and silently restate history. Nothing errors; the number is simply wrong.
  • Quoting list price as the bill. system.billing.list_prices knows nothing about the client's commit discount — the relative split is right, the absolute number is not, and the two look identical on a slide.
  • Chasing the discrepancy between the budget page, the usage system table and yesterday's email. System tables refresh every few hours and the surfaces genuinely disagree about today; compare windows that are at least a day closed instead of debugging the difference.
  • One budget for the whole account. It fires, everyone looks, nobody can say which team moved, and by the third time people have stopped looking.

Straight to the documentation

The generic platform material lives in the official docs and is not restated here. These are the pages worth having open while you work through this route.

Platform facts on this page verified 29.07.2026 against the official documentation. Volatile claims are anchored to the currency register.