The Snowflake bill for our CI account had roughly tripled over a quarter, and nobody could point to why. We hadnât loaded meaningfully more data. Compute was flat. But storage kept climbing, month over month, in an account whose entire job was to spin up throwaway test environments and tear them down. Throwaway. Torn down. And yet the storage line kept going up and to the right.
The culprit was the feature Iâd been recommending to everyone as âbasically freeâ: zero-copy clones. Our CI pipeline cloned production on every pull request, ran migrations and tests against the clone, and dropped it at the end. Textbook. The problem is that âzero-copyâ describes the moment of creation and nothing after it, and âdropâ doesnât mean what you think it means when clones are involved. We were paying for storage we believed weâd deleted weeks ago.
This is the guide to why that happens, how to find it in your own account, and how to stop it. If you run clone-based CI/CD at any scale, some version of this is almost certainly happening to you right now.
Why âzero-copyâ is a half-truth
Snowflake stores table data in immutable micro-partitions â compressed columnar files, tens to hundreds of MB each. Once written, a micro-partition is never modified. When you clone a table, Snowflake doesnât copy those files. It writes a new metadata entry pointing at the same set of micro-partitions. Thatâs why a clone of a 5 TB table is instant and costs nothing extra at that instant. Itâs a hard link at the partition level, not a copy.
The word âzero-copyâ describes exactly that instant and no other. Because micro-partitions are immutable, the moment you change a row â in the clone or the original â Snowflake canât edit the shared partition in place. It writes a new micro-partition containing the change, and that new partition is owned exclusively by whichever side made the change. The unchanged partitions stay shared. So your storage cost isnât the size of the clone; itâs the size of the divergence between the clone and its source.
The correct mental model, which took me an embarrassingly large bill to internalize: a clone is not a free copy, itâs an instant branch that gets more expensive as it diverges. Read-only clone of 1 TB? Costs nothing. Clone you fully rewrite? Costs a second 1 TB. Real CI workloads land in between â and âin between,â multiplied by every pull request, is a budget line.
Where the cost actually enters in CI/CD

The clone is free at step 1. Your migration at step 2 is what creates billed storage â and step 4âs drop doesnât reclaim it right away.
Hereâs the standard CI pattern, the one in every tutorial:
SQL example â read the query, then copy it into your warehouse.
CREATE DATABASE ci_test_${BUILD_ID} CLONE production_db;-- run migrations against the clone-- run integration testsDROP DATABASE IF EXISTS ci_test_${BUILD_ID};
Step one is genuinely free. The cost enters at ârun migrations.â A migration that adds a column, backfills a value, rebuilds a table, or runs a dbt model against the clone writes new micro-partitions for every affected partition. If your migration touches 10% of a 500 GB table, you just materialized ~50 GB of new storage â for one CI run. Run that pipeline 40 times a day across a team and the daily divergence is measured in terabytes of writes, even though each individual run âonlyâ changed a slice.
None of that is visible while youâre looking at it, because the clone gets dropped at the end and the environment looks clean. Which brings us to the part that actually generates the surprise bill.
The two things that keep paying after you âdeleteâ
1. Dropping a table doesnât free its bytes immediately. When you DROP a permanent table (or database), it doesnât evaporate â it goes into Time Travel for its retention period (default 1 day, and up to 90), and then into Fail-safe for a further 7 days, during which only Snowflake can recover it. Throughout both windows youâre billed for those bytes. A CI loop that creates and drops clones dozens of times a day is continuously feeding a rolling backlog: at any given moment youâre paying for the Time-Travel-and-Fail-safe tail of every clone dropped in roughly the last week, not just the ones alive right now.
2. Clone-group ownership transfer â the one that breaks intuition. Every table in a clone group has an independent lifecycle, but the storage for shared micro-partitions is owned by the oldest table in the group. Hereâs the trap: you decide the source table is the problem and drop it. You expect storage to fall. It doesnât. Because a clone still references those shared partitions, Snowflake canât release them â so when theyâd otherwise exit Time Travel, ownership transfers to a surviving clone instead. You deleted the original and the bytes simply changed owner. This is why teams stare at a dropped production backup table and canât understand why the account storage didnât budge.
Finding it in your own account

Four columns tell the whole story. RETAINED_FOR_CLONE_BYTES is the one that reveals storage kept alive purely because a clone still references it.
The view you want is SNOWFLAKE.ACCOUNT_USAGE.TABLE_STORAGE_METRICS. It breaks every tableâs footprint into the buckets that matter:
SQL example â read the query, then copy it into your warehouse.
SELECT table_catalog, table_schema, table_name, active_bytes / POW(1024,3) AS active_gb, time_travel_bytes / POW(1024,3) AS time_travel_gb, failsafe_bytes / POW(1024,3) AS failsafe_gb, retained_for_clone_bytes / POW(1024,3) AS clone_retained_gbFROM snowflake.account_usage.table_storage_metricsWHERE retained_for_clone_bytes > 0ORDER BY retained_for_clone_bytes DESC;
ACTIVE_BYTES is your live data â the part you expect to pay for. TIME_TRAVEL_BYTES and FAILSAFE_BYTES are the recovery tails. RETAINED_FOR_CLONE_BYTES is the smoking gun: bytes that are only still on disk because some clone in the group references them. If that column is large on tables you thought were long gone, youâve found your leak.
To hunt CI clones specifically, filter by naming convention and age. Because Snowflake records clone lineage, you can surface old clones still retaining significant storage:
SQL example â read the query, then copy it into your warehouse.
SELECT table_catalog, table_name, clone_group_id, retained_for_clone_bytes / POW(1024,3) AS clone_retained_gb, table_createdFROM snowflake.account_usage.table_storage_metricsWHERE table_catalog ILIKE 'CI_TEST_%' AND retained_for_clone_bytes > 0ORDER BY clone_retained_gb DESC;
One caveat worth knowing: ACCOUNT_USAGE views have latency (often a couple of hours), so donât expect a drop you ran five minutes ago to show up instantly. Debug against yesterdayâs picture, not this secondâs.
The cost math, concretely
Say production is 2 TB and your CI migration reliably rewrites ~8% of it per run: ~160 GB of new micro-partitions per pipeline. The clone is dropped at the end, so those 160 GB immediately become Time Travel + Fail-safe bytes rather than active bytes â and they linger for the retention tail. With a 1-day Time Travel plus 7-day Fail-safe window on permanent objects, each runâs divergence sticks around for roughly 8 days before itâs physically purged.
Run the pipeline 30 times a day and, in steady state, youâre carrying roughly 30 runs/day Ă 8 days Ă 160 GB â 38 TB of retained bytes that you believe you deleted. At standard on-demand storage rates thatâs a four-figure monthly line for data that exists only because âdropâ isnât âdeleteâ and permanent tables carry a Fail-safe tail. The exact number depends on your migrationâs write volume and your retention settings â but the shape is always the same, and itâs always bigger than teams expect.
The fixes, in priority order
Clone into transient objects for CI. This is the single biggest lever. Transient tables and databases have no Fail-safe period and a Time Travel retention of 0 or 1 day. Clone production into a transient database for CI, and when you drop it thereâs no 7-day Fail-safe tail â the bytes are reclaimable almost immediately. CREATE TRANSIENT DATABASE ci_test_${BUILD_ID} CLONE production_db; Note the sourceâs own storage behavior is unchanged; this only governs the CI-side lifecycle, which is exactly the part generating your backlog.
Set Time Travel to zero on CI objects. If you canât use transient objects for some reason, at least set DATA_RETENTION_TIME_IN_DAYS = 0 on the CI database so dropped clones donât linger in Time Travel. Combined with the above, your CI divergence becomes genuinely short-lived.
Actually drop, even on failure. The tutorial pattern drops the clone at the end â but if tests fail and the pipeline exits early, the DROP may never run. Orphaned clones from failed builds are a classic source of retained storage. Put the DROP in a finally/always block so it runs regardless of test outcome, and add a scheduled sweeper task that drops any CI_TEST_% database older than a few hours as a backstop.
Diverge less. Ask whether your CI actually needs to rewrite 8% of a 2 TB table. Often the migration under test only needs to run against a representative subset, or the test only needs schema validation, not a full data backfill. Cloning gives you production-realistic structure for free; you donât always need to exercise it against production-scale writes.
Mind the ownership trap when cleaning up. If youâre deleting old backup clones to reclaim space, remember that dropping the oldest member of a clone group transfers ownership rather than freeing bytes. To actually reclaim storage from a clone group, you generally need to drop all members that reference the shared partitions and let the retention windows expire. Deleting one and expecting the bill to fall is how the confusion starts.
The gotchas nobody warns you about
Grants diverge at clone time. A clone inherits grants and masking policies from the source at the instant of cloning, then becomes independent. For CI this is usually fine, but if your pipeline relies on grants applied to production after the clone was taken, they wonât be there.
Small-file defragmentation writes Time Travel bytes too. Even plain INSERT/COPY/Snowpipe loads can generate Time Travel and Fail-safe bytes, because Snowflake periodically compacts small micro-partitions â deleting the small ones (which enter the recovery tail) and writing a consolidated one. So retained bytes arenât exclusively a clone phenomenon; clones just amplify it.
External tables, stages, and pipes donât clone. If your CI environment depends on them, cloning the database wonât bring them along â youâll need to recreate them in the clone.
ACCOUNT_USAGE latency hides fast loops. Because the storage views lag by up to a few hours, a tight CI loop can be generating and âhidingâ retained storage faster than your dashboards refresh. Trust the trend over days, not the instantaneous number.
The one principle
Zero-copy cloning is free to create and expensive to diverge â and âdropâ is not âdelete.â In CI/CD, the storage you pay for is the write volume of your migrations times the retention tail of your dropped clones. Clone into transient objects, keep Time Travel short, drop reliably, and diverge only as much as the test actually requires. The feature isnât lying to you; itâs just describing creation, not the whole lifecycle. Manage the lifecycle and the âfreeâ clone stays close to free.
Related reading: Snowflake data storage considerations (clone groups & CDP) · TABLE_STORAGE_METRICS view reference · dbt State on Snowflake: Skip Unchanged Models · Snowflake Query Execution: What Really Happens · Snowflake Iceberg v3: When to Migrate
Question this article answers
The short answer first. Open it to read the working note.
Where the cost actually enters in CI/CD?
The clone is free at step 1. Your migration at step 2 is what creates billed storage â and step 4's drop doesn't reclaim it right away. Here's the standard CI pattern, the one in every tutorial:
