Snowflake Time Travel Storage Cost: Find Bloated Tables
Your active data is flat but Snowflake storage keeps climbing. Here is the SQL to find the tables responsible, and the dbt config that fixes it.
Snowflake Time Travel storage cost is the charge for the old versions of rows that a table keeps after every UPDATE, MERGE or rebuild. On permanent tables, a 7-day Fail-safe period follows the Time Travel window, and that is billed as well. To find the tables causing it, query SNOWFLAKE.ACCOUNT_USAGE.TABLE_STORAGE_METRICS and sort by TIME_TRAVEL_BYTES plus FAILSAFE_BYTES. In dbt projects the usual culprit is a heavily rebuilt table that is permanent when it should be transient.
The symptom looks like this. Your row counts are roughly flat, but the storage line on the Snowflake invoice keeps creeping up. Someone checks the table sizes in Snowsight, and they look reasonable. The bytes you are paying for are not in those numbers, because the size shown for a table covers only its current state. Data you have changed or deleted stays on the bill until both Time Travel and Fail-safe have expired.
One honest caveat first. For most teams storage is the small part of the bill. One 2025 pricing breakdown puts compute at 80 to 90 percent of typical spend and storage at around 10 percent. If your credits are the problem rather than your terabytes, start with our post on Snowflake idle warehouse cost. But this fix takes about ten minutes, never touches query performance, and it comes up in almost every Snowflake audit we run, so it is worth doing.
Why Is Your Snowflake Time Travel Storage Cost Climbing When Your Data Is Not?
Snowflake does not copy a whole table for every change. It keeps only what is needed to restore the rows that were updated or deleted, so storage is calculated as a percentage of the table that changed. That sounds cheap until you count how much of a table changes when dbt rebuilds it. Each fee is calculated for every 24-hour period from the time the data changed.
Snowflake's own documentation has a worked example that shows the multiplier. A 200 GB high-churn dimension table backed up once a day carries 200 GB of Time Travel and 1.4 TB of Fail-safe, about 2 TB in total. That is ten times the active data, for a table nobody would call large.
For a dbt project the arithmetic works like this. Take a permanent 500 GB mart that is fully rebuilt every day. It carries roughly 500 GB of Time Travel and seven days of rebuilt versions in Fail-safe, which is about 4 TB of non-active storage behind 0.5 TB of active data. Multiply that by every rebuilt table across staging, intermediate and marts.
A pattern we see repeatedly in early-stage Snowflake accounts is that a blanket +transient: false was copied into dbt_project.yml during a rushed migration. Raw tables loaded by an ingestion tool are permanent by default too. Nobody revisits either setting, because storage never triggers an alert. It only shows up as a slow upward drift in the bill.
On price, Snowflake's published AWS US East rate is $23 per TB per month. Some sources still quote $40 per TB as the on-demand list price. Check your own contract, because the right number changes the dollar figure but not the diagnosis.

📺 Watch: Why Snowflake's Pricing Costs Way More Than Advertised
How Do You Find Tables With Bloated Time Travel and Fail-safe Storage?
Run these in a worksheet with a role that can read the SNOWFLAKE.ACCOUNT_USAGE schema. The view can lag by up to a couple of hours, so don't expect a change you made ten minutes ago to show up.
Start with the top 25 offenders by protection bytes (Time Travel plus Fail-safe):
SELECT table_catalog,
table_schema,
table_name,
is_transient,
ROUND(active_bytes / POWER(1024, 3), 1) AS active_gb,
ROUND(time_travel_bytes / POWER(1024, 3), 1) AS time_travel_gb,
ROUND(failsafe_bytes / POWER(1024, 3), 1) AS failsafe_gb,
ROUND((time_travel_bytes + failsafe_bytes)
/ NULLIF(active_bytes, 0), 1) AS overhead_ratio
FROM snowflake.account_usage.table_storage_metrics
WHERE deleted = FALSE
AND active_bytes > 0
ORDER BY (time_travel_bytes + failsafe_bytes) DESC
LIMIT 25;
An overhead_ratio above 2 on a table that is not an irreplaceable source of truth is a candidate. A ratio of 8 to 10 on a table dbt rebuilds daily is almost always a permanent table that should be transient.
Next, roll it up by schema so you can see where the money sits. The 23 is the per-TB rate, so swap in your own:
SELECT table_schema,
ROUND(SUM(active_bytes) / POWER(1024, 4), 2) AS active_tb,
ROUND(SUM(time_travel_bytes + failsafe_bytes) / POWER(1024, 4), 2) AS protection_tb,
ROUND(SUM(time_travel_bytes + failsafe_bytes) / POWER(1024, 4) * 23, 0) AS est_monthly_usd
FROM snowflake.account_usage.table_storage_metrics
WHERE table_catalog = 'ANALYTICS'
AND deleted = FALSE
GROUP BY table_schema
ORDER BY est_monthly_usd DESC;
Then look at dropped tables. Every full-refresh or swap in dbt leaves a dead table behind that you still pay for until its retention windows close:
SELECT table_catalog, table_schema, table_name, table_dropped,
ROUND((time_travel_bytes + failsafe_bytes) / POWER(1024, 3), 1) AS gb_still_billed
FROM snowflake.account_usage.table_storage_metrics
WHERE deleted = TRUE
AND (time_travel_bytes + failsafe_bytes) > 0
ORDER BY gb_still_billed DESC
LIMIT 20;
If the schema-level view shows staging and intermediate schemas near the top, that is your answer. Those layers are rebuilt constantly and should almost never need a week of Fail-safe.
How Do You Check Whether dbt Is Building Permanent Tables?
The warehouse tells you what exists. The dbt project tells you why. Start with the project side, listing every model that has transient explicitly switched off:
dbt ls --resource-type model --select config.transient:false
The config selector works on keys set in model, folder or project config. On older dbt versions the syntax may differ slightly, so check dbt ls --help. An empty result does not mean you are safe. Tables created outside dbt, or in a schema with a high retention setting, will not appear here.
So confirm from the Snowflake side. This lists permanent tables with more than one day of Time Travel retention, ordered by size:
SELECT table_schema, table_name, is_transient, retention_time,
row_count, ROUND(bytes / POWER(1024, 3), 1) AS gb
FROM analytics.information_schema.tables
WHERE table_type = 'BASE TABLE'
AND is_transient = 'NO'
AND retention_time > 1
ORDER BY bytes DESC;
Finally, check where the retention number is inherited from:
SHOW PARAMETERS LIKE 'DATA_RETENTION_TIME_IN_DAYS' IN ACCOUNT;
SHOW PARAMETERS LIKE 'DATA_RETENTION_TIME_IN_DAYS' IN DATABASE analytics;
We have found 30 or 90 day retention set at account level for a compliance reason that applied to two tables. Every other table in the account inherited it, so the whole warehouse was paying for it.
Running that query for one database is fine. Checking every model's config across a whole project is not. fintel-scan is a free, open-source (MIT) CLI that runs fourteen static checks on a dbt project locally, with no warehouse connection: uvx fintel-scan. It is worth running next to this audit, because storage config drift rarely travels alone.
How Do You Reduce Snowflake Time Travel Storage Cost Without Losing Recoverability?
The rule we use is simple. If dbt can rebuild a table from upstream data in minutes, it doesn't need Fail-safe. Transient tables have Time Travel of 0 or 1 day and no Fail-safe period. That caps their storage overhead at at most one day's worth of storage cost. The catch is that Snowflake cannot recover historical data in a transient table after the Time Travel period ends. Use them only for data you can reproduce.
| Table type | Examples | Recommended setting |
|---|---|---|
| Rebuilt by dbt on a schedule | staging, intermediate, most marts | Transient, 0 to 1 day Time Travel |
| Loaded by ingestion tools, no replay source | raw landing tables | Permanent, 1 to 7 days Time Travel |
| Cannot be rebuilt from source | dbt snapshots, ledgers, audit tables | Permanent, retention matched to your recovery need |
| Short-lived scratch work | dev schemas, CI builds | Transient, 0 days |
The docs make the same point for tables you cannot afford to lose: long-lived tables such as fact tables should be permanent so Fail-safe fully protects them. If a mart takes six hours to rebuild and the business cannot wait, keep it permanent. The point is to decide deliberately, not to inherit a default.
The dbt_project.yml change looks like this:
models:
my_project:
+transient: true
staging:
+pre-hook: []
marts:
core_ledger:
+transient: false
snapshots:
my_project:
+transient: false
Then drop retention on the schemas that hold rebuildable data:
ALTER SCHEMA analytics.staging SET DATA_RETENTION_TIME_IN_DAYS = 0;
ALTER SCHEMA analytics.intermediate SET DATA_RETENTION_TIME_IN_DAYS = 0;
ALTER SCHEMA analytics.marts SET DATA_RETENTION_TIME_IN_DAYS = 1;
Existing tables will not change type just because the config changed. Run a full rebuild of the affected models so dbt recreates them as transient tables:
dbt run --full-refresh --select config.transient:true
Expect a tail. The old permanent versions drop into Time Travel and then Fail-safe, so your storage stays elevated for roughly eight days after the change before it falls. If nobody warns the finance team, that looks like the fix made things worse. Re-run the schema roll-up after ten days. In our experience the non-active storage on rebuilt schemas should drop to about a day of churn.
BigQuery has its own version of this problem. It is covered in our post on BigQuery logical vs physical storage in dbt.

How Do You Stop Snowflake Storage Bloat Coming Back?
Cleaning it up once is the easy part. Three guardrails keep it from coming back.
Default new schemas to transient. Tables created inside a transient schema or database are transient unless you say otherwise. Create dbt target schemas that way:
CREATE TRANSIENT SCHEMA IF NOT EXISTS analytics.marts DATA_RETENTION_TIME_IN_DAYS = 1;
Put a threshold on the audit. Save a version of the first query as a weekly check in your orchestrator, and fail it loudly when a permanent table crosses a limit:
SELECT table_schema, table_name
FROM snowflake.account_usage.table_storage_metrics
WHERE deleted = FALSE
AND is_transient = 'NO'
AND active_bytes > 50 * POWER(1024, 3)
AND (time_travel_bytes + failsafe_bytes) > 3 * active_bytes;
Review permanent tables at code review. Any pull request that sets +transient: false should explain why in the description. This takes thirty seconds and catches the blanket-config mistake before it spreads.
This kind of quiet cost leak is one of the cheapest things to fix and one of the easiest to miss. It sits alongside runaway queries and unowned schemas as one of the failures a missing governance layer allows. If you want a second pair of eyes on your warehouse, explore our services or see how we help businesses like yours audit Snowflake and BigQuery estates.
Frequently Asked Questions
Q: How much does Snowflake Time Travel cost?
A: Time Travel is billed as ordinary storage at your contract rate, on the data that changed. Fees are calculated for each 24-hour period from the time the data changed. The cost therefore depends on how much of each table you rewrite, not on table size. A rarely changed table costs almost nothing, and a rebuilt-daily table can cost several times its active size.
Q: Do transient tables have Fail-safe in Snowflake?
A: No. Transient and temporary tables have no Fail-safe period. Their Time Travel is limited to 0 or 1 day, even on Enterprise Edition. Only permanent tables carry the 7-day Fail-safe, and you cannot switch it off on a permanent table.
Q: Does dbt create transient tables on Snowflake by default?
A: In dbt-snowflake, table models are created as transient unless you set +transient: false. Raw tables loaded by other tools and anything created manually are permanent by default. Check both with TABLE_STORAGE_METRICS rather than assuming.
Q: How do I check Time Travel storage per table in Snowflake?
A: Query SNOWFLAKE.ACCOUNT_USAGE.TABLE_STORAGE_METRICS and compare ACTIVE_BYTES, TIME_TRAVEL_BYTES and FAILSAFE_BYTES for each table. Filter on DELETED = FALSE for live tables, or DELETED = TRUE to find dropped tables still being billed. The view can lag behind real activity by a couple of hours.
Q: Is it safe to set Time Travel to 0 days?
A: It is safe for tables you can rebuild from upstream data, such as staging and intermediate models. It is not safe for snapshot tables, raw landing tables with no replay source, or anything audit-relevant. Decide per table type, and record the reason in your dbt config.
Conclusion
The hard part of Snowflake Time Travel storage cost is that nothing in the day-to-day tooling warns you about it. Your data looks small, your queries run fine, and the overhead builds up in the background on permanent tables that dbt rebuilds every day. At Fintel Analytics, we have helped fast-growing fintech, payments and e-commerce teams audit their Snowflake and BigQuery estates, from storage config and query governance through to a dbt project that stays cheap as it scales. If your storage line has been drifting upward and nobody can say why, that is a fixable problem, and the fix usually pays for itself in the first billing cycle.
