Expired isn't gone: Auto-TTL's real timing contract and the 17-day math
You set DELETE ROWS 30 DAYS AFTER created_at and promised 30-day erasure. Then the audit found 35-day-old rows still on storage. Auto-TTL never promised a deadline — expiration is just eligibility. Deletion runs asynchronously: up to 6 days of buffer while predictive optimization cycles DELETE and VACUUM, plus your full deleted-file retention window while rows stay time-travel accessible. The tactic: size the policy with the docs' formula — target_days − 6 − retention — so hitting a 30-day removal target means setting DELETE ROWS 17 DAYS AFTER.
Built for engineers who need an answer they can test in the next notebook, job run, or incident review.
TIP · 3 MIN
Size the policy, not the promise
Set expiration_days = target_days − 6 − deletedFileRetentionDuration. A 30-day removal target with default retention means setting expiration to 17 days — and verify against the operations history, not the deadline.
The policy is one line of SQL. The trust comes from monitoring system.storage.predictive_optimization_operations_history — and from skipChangeCommits on every streaming reader.
Expiration ≠ deletion ≠ gone. The 6-day buffer, the retention window that keeps rows time-travel accessible, and the query that shows what's actually happened.
Last verified against Databricks docs: September 30, 2026. Corrections: Sep 30, 2026 — reframed the "hard 30-day guarantee" as a 30-day removal target; removed the unsupported extra PURGE stage; replaced the dead GitHub source link with the official Databricks platform release notes.
The audit finding is not a bug — it is the documented contract. DELETE ROWS 30 DAYS AFTER created_at makes rows eligible for deletion at 30 days. Eligibility is where your control ends: predictive optimization picks them up asynchronously, and the docs are explicit that exact deletion timing is not guaranteed. If you promised the audit team day-30 erasure, you promised something the feature never offered.
Reproducible example — illustrative. A demo table with five rows aged 1 to 40 days, and the docs' own 30-day-target example (30 − 6 − 7 = 17):
-- Illustrative demo table
CREATE TABLE demo.events (
id BIGINT,
event_ts TIMESTAMP
);
-- 5 illustrative rows, event_ts from 1 to 40 days ago
INSERT INTO demo.events VALUES
(1, current_timestamp() - INTERVAL 40 DAYS),
(2, current_timestamp() - INTERVAL 25 DAYS),
(3, current_timestamp() - INTERVAL 17 DAYS),
(4, current_timestamp() - INTERVAL 5 DAYS),
(5, current_timestamp() - INTERVAL 1 DAY);
-- The docs' formula: target_days − 6 − deletedFileRetentionDuration.
-- A 30-day removal target with default 7-day retention → set 17 days.
ALTER TABLE demo.events DELETE ROWS 17 DAYS AFTER event_ts;
-- Confirm the policy registered
SHOW TBLPROPERTIES demo.events;
-- Expected: autottl.expireInDays = 17, autottl.timestampColumn = event_ts
-- Immediately after the ALTER: old rows are STILL returned.
-- Expected: rows 1–3 present — eligibility, not erasure.
SELECT min(event_ts), max(event_ts) FROM demo.events;
What to expect, honestly. Immediately after the ALTER, the old rows are still returned — expected: nothing has run yet; the pipeline is asynchronous. SHOW TBLPROPERTIES should show autottl.expireInDays = 17 and autottl.timestampColumn = event_ts — expected: the policy is registered. Over the following days the background DELETE runs (up to ~3 days of buffer) and VACUUM clears the old versions (another ~3 days) — then the 7-day deleted-file retention window runs before the bytes are truly unrecoverable. Illustrative end state: rows gone from storage around day 43 from a 30-day promise. One honest caveat: the end-to-end wait is not reproducible in a single notebook session — what you can verify today is eligibility (TBLPROPERTIES) and, over the coming days, the operations in the system table.
How to validate: run the operations-history query in the implementation section daily after enabling the policy. Expect the first DELETE row within ~3 days of rows expiring, then VACUUM, up to ~3 days later. If nothing appears after 6+ days past expiry, check the two silent killers: predictive optimization must be enabled (turning it off stops auto-TTL with no error), and the table must be a Unity Catalog managed Delta or Iceberg table on DBR 17.3+ (older runtimes can read and write but cannot manage the policy).
A second docs example worth pocketing: a 90-day removal target with a 30-day configured retention needs 54 days (90 − 6 − 30). The 6-day buffer is constant; your retention window is the variable.
Illustrative scenario: a data platform team puts DELETE ROWS 30 DAYS AFTER event_ts on a Unity Catalog managed clickstream table and tells the compliance team that user-level PII is erased 30 days after capture. At day 35, the audit samples the table: the rows are still queryable — and the bytes are still on storage. Nobody misconfigured anything. Expiration fired on schedule; the rows were eligible. But the background pipeline was still working through its up-to-6-day buffer, and the 7-day deleted-file retention window kept the rows time-travel accessible regardless. The team had sized the policy with the deadline, not the math.
1. Check the pre-reqs — the feature has a silent kill switch
Unity Catalog managed Delta or Iceberg table. Predictive optimization must be enabled — turning it off silently stops auto-TTL; no error, no warning. DBR 17.3 or above (17.2 and below can read and write the table but cannot manage the policy). A DATE, TIMESTAMP, or TIMESTAMP_NTZ column — nested fields work via dot notation (payload.ts) — and MODIFY permission on the table. One limitation to plan around now: the TTL time column cannot be renamed later, even with column mapping enabled.
2. Size the policy with the formula, then set it
Two docs examples to memorize. 30-day removal target with the default 7-day retention: 30 − 6 − 7 = 17 days. 90-day target with a 30-day configured retention: 90 − 6 − 30 = 54 days. The 6-day buffer is constant (up to 3 days per command: DELETE, then VACUUM); your retention window is the variable.
-- Sized with the formula: 30-day target, default 7-day retention → 17 days
ALTER TABLE catalog.schema.events DELETE ROWS 17 DAYS AFTER event_ts;
3. Know the pipeline: DELETE, then VACUUM
Predictive optimization deletes the expired rows, then VACUUM clears the old versions. Each command has up to ~3 days of buffer. And as Issue 005 covered, VACUUM uses the table's configured deleted-file retention (7-day default, 7-day minimum) — the floor that keeps expired rows recoverable until it passes.
4. Monitor it like a pipeline, not a setting
The saveable takeaway of this issue — the query that replaces deadline-watching with evidence:
-- SAVE THIS: the Auto-TTL operations monitor.
-- Shows the actual DELETE/VACUUM timestamps so you stop
-- trusting the deadline and start trusting the pipeline.
-- (Column names verified against the official predictive-optimization
-- system table reference.)
SELECT
start_time,
operation_type,
operation_status,
operation_metrics
FROM system.storage.predictive_optimization_operations_history
WHERE catalog_name = 'catalog'
AND schema_name = 'schema'
AND table_name = 'events'
AND operation_type IN ('DELETE', 'VACUUM')
ORDER BY start_time DESC;
Add a DBSQL alert on failed operations so a stalled DELETE or a failed VACUUM pages someone instead of silently missing your erasure target. Note the status vocabulary: operation_status is SUCCESSFUL or FAILED: … — including FAILED: AUTO_TTL_COLUMN_DOES_NOT_EXIST_ERROR and FAILED: PRIVATE_LINK_SETUP_ERROR — and the table name columns are separate (catalog_name, schema_name, table_name), so filter on all three, not on a three-part path. DESCRIBE HISTORY shows the per-table operation trail if you need it at table scope.
5. Fix the silent production breaker: streaming readers
Every Structured Streaming job reading the table breaks on the first background deletion — auto-TTL deletions appear as data changes, and the read fails without skipChangeCommits. It is the companion incident waiting to happen, and it is one option away from never happening:
# A structured streaming job reading a TTL table breaks on the
# first background deletion unless you skip the change commits.
safe_read = (
spark.readStream.format("delta")
.option("skipChangeCommits", "true")
.table("catalog.schema.events")
)
6. GDPR hard erasure: never claim 'deleted' before PURGE
On deletion-vector tables, soft-deleted rows remain physically present in the current files until PURGE rewrites them — and every deleted row stays time-travel recoverable until VACUUM plus the retention window clears the old versions. If an audit needs proof of byte-level erasure on a schedule, don't rely on the async pipeline: run the manual chain — DELETE, then REORG TABLE ... APPLY (PURGE), then VACUUM — per the Azure Databricks GDPR guidance.
Use this when: time-based retention or compliance erasure on UC-managed Delta or Iceberg tables; replacing cron DELETE + VACUUM jobs with a declarative policy; cost cleanup of aging event data; GDPR right-to-erasure backstops where the formula gives you margin to spare.
Avoid this when: you need an exact, auditable deletion deadline — timing is explicitly not guaranteed; on materialized views, which don't support it; when downstream streams read the table and you can't set skipChangeCommits; when the TTL time column might ever be renamed; when you need proof of byte-level erasure on a schedule — run manual DELETE + REORG PURGE + VACUUM instead.
Size with target_days − 6 − deletedFileRetentionDuration: 30 → 17 with defaults.
Set it: ALTER TABLE ... DELETE ROWS N DAYS AFTER ts; confirm with SHOW TBLPROPERTIES (autottl.expireInDays, autottl.timestampColumn).
Monitor system.storage.predictive_optimization_operations_history for DELETE/VACUUM; alert on failures.
Add skipChangeCommits=true to every streaming reader on the table.
For GDPR proof on deletion-vector tables: PURGE rewrites the files, VACUUM + the retention window clears the old versions — claim nothing before that chain completes.
Why are expired rows still returned — and still on storage?
The deadline passed. The rows are still queryable. Here's the checklist — expiration is not erasure.
The answer
Expiration marks eligibility; nothing is deleted the day rows expire. The docs: "Exact deletion timing is not guaranteed and may vary based on system load." Your 30-day policy promised day-30 eligibility, not day-30 erasure.
The buffer: predictive optimization runs DELETE, then VACUUM — each up to ~3 days behind — so up to 6 days can pass between expiry and permanent removal. A 30-day setting delivers eligibility-to-removal of roughly 36 days, before retention.
The retention window: deleted rows remain in storage, time-travel accessible, for the table's configured delta.deletedFileRetentionDuration (7-day default) — the same floor Issue 005 covered. With defaults, "gone from storage" can land ~13 days after expiry — ~43 days from a 30-day setting (illustrative). Size policies with target_days − 6 − retention: 30 → 17 days.
Check the operations-history query instead of the deadline — it shows the actual DELETE and VACUUM timestamps for your table. No rows 6+ days past expiry? Check predictive optimization is on (its off-switch is silent) and you're on DBR 17.3+.
Deletion-vector tables: soft-deleted rows stay physically in current files until PURGE rewrites them — claim nothing before PURGE + VACUUM + the retention window completes. For a scheduled proof, run manual DELETE + REORG TABLE ... APPLY (PURGE) + VACUUM.
Applies to: Databricks Runtime 17.3+; Unity Catalog managed Delta Lake and Apache Iceberg tables (streaming tables set auto_ttl in Lakeflow pipeline code instead of ALTER). The scenario and all figures in it are illustrative. Last verified September 30, 2026 against official Databricks documentation. Corrections: Sep 30, 2026 — reframed the "hard 30-day guarantee" as a 30-day removal target; removed the unsupported extra PURGE stage; replaced the dead GitHub source link with the official Databricks platform release notes.
Lakehouse Digest is a short, practical newsletter for Databricks engineers, published by the Lakehouse Digest team. Each issue delivers one tactic to try, one production pattern to understand, and one hard developer question answered with code and primary-source links. It is an independent publication and is not affiliated with Databricks, Inc.
RECOMMENDED LEARNING
Build the skill behind the fix.
Topic-led learning paths for readers who want to turn this issue’s tactics into repeatable Databricks practice.
START HERE
Databricks fundamentals
Strengthen the platform basics behind notebooks, jobs, data workflows, and collaborative development.
Partner links: we may earn a commission if you enroll through these recommendations, at no extra cost to you.
THE NEWSLETTER
One useful Databricks issue, built for the working session.
Concise implementation notes, real failure modes, and developer answers—without a news recap you could get anywhere.
What lands in each issue
A tactic to try, a production pattern to understand, and one hard developer question answered with code and primary-source links.
SIGNUPEmail delivery is on the way.
The email version of Lakehouse Digest isn't wired up yet. A subscribe form will appear here the moment it launches — nothing on this page collects your address today.
SPONSORSHIP
Sponsor Lakehouse Digest.
A technical audience of Databricks engineers, reading implementation notes they can test the same day.
Launch inventory is one clearly labeled, problem-first placement per issue — built for products that help data teams build, govern, observe, or ship more reliably.