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.
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
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.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.';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.-- 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;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.
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
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.-- 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;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.
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
dwh_dev, dwh_test and dwh_prod is managed, with two named exceptions.-- 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.| Managed | External | |
|---|---|---|
| Predictive optimization | yes | no — never |
| DROP TABLE | deletes the data; UNDROP within 7 days | metadata only; files remain |
| Storage location | UC chooses; you never see a path | your path, your lifecycle, your problem |
| Justified when | always, unless the row below applies | another system writes the files, or a non-Databricks engine reads them in place |
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.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.
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
| Workload | Compute | Why |
|---|---|---|
| Lakeflow pipelines, scheduled jobs | serverless | no idle time between runs; nothing to size |
| SQL, BI, metric views over gold.mv_sales | serverless SQL warehouse | starts in seconds, so a short auto-stop is free |
| Ad-hoc notebook work | serverless notebook | the alternative is a cluster somebody forgets |
| Native libraries serverless cannot take | job compute under a policy | the exception — it should need a sentence of justification |
# 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: prodDoing 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.
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
GROUP BY.| Key | Values | Set where | Answers |
|---|---|---|---|
| domain | sales | finance | platform | compute policy, fixed per policy | whose budget is this |
| environment | dev | test | prod | bundle target | production spend or experiment |
| cost_centre | CC-4711 and friends | compute policy | which line in the finance system |
| owner | group address, never a person | compute policy | who to ask before switching it off |
# 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.comDoing 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.
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
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;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;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.
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.
- Use liquid clustering for tables — the four-key limit, CLUSTER BY AUTO, and the exact REPLACE PARTITIONED BY WITH CLUSTER BY syntax
- Predictive optimization for Unity Catalog managed tables — read this BEFORE writing any maintenance job — it lists the table types it does not cover
- Compute policy reference — the policy attribute types; 'fixed' is the one that makes auto-termination and tags non-negotiable
- Billable usage system table reference — the full column list for system.billing.usage, including what usage_metadata carries per product
- Attribute usage with serverless usage policies — the only mechanism that gets your tags onto serverless spend — check its preview status before committing to it