zero-copy cloning is the feature that lets you branch a 40-terabyte production table into a private sandbox in the time it takes to press Enter — and it works because the clone copies the metadata, the list of pointers to the immutable storage blocks, and never the bytes those pointers reference. The moment you understand that a modern analytical table is a set of never-mutated storage blocks plus a manifest that says which blocks belong to the table, the "magic" evaporates: cloning is just writing a second manifest that points at the same blocks. Nothing is duplicated at creation time, so the operation is bounded by metadata size rather than data size, and the new sandbox starts life costing effectively nothing in storage.
The reason this matters far beyond a party trick is that it collapses the oldest tension in data engineering — you want to test migrations, run analysts loose, and gate deployments against real production-shaped data, but you cannot afford to physically copy that data for every developer, every pull request, and every nightly job. copy-on-write is the second half of the answer: the clone and its source share blocks until someone writes, and only the changed block is forked into a new copy that the writer pays for. This guide opens the black box in layers — the pointer-and-fork mechanics, the snowflake clone syntax across tables, schemas, and databases with time travel, the databricks shallow clone versus deep-clone split, the dev/test data and CI patterns that make it operational, and finally the storage cost and divergence pitfalls that turn a free feature into a surprising bill. Each section pairs a teaching block with a Solution-Tail interview answer — code, a step-by-step trace, an output table, then a concept-by-concept breakdown of why it works.
When you want hands-on reps immediately after reading, drill the database practice library →, rehearse pipeline mechanics on the data-processing practice library →, and sharpen the cost intuition with the optimization practice library →.
On this page
- How zero-copy cloning actually works
- Snowflake CLONE — databases, schemas, tables
- Databricks shallow vs deep clone
- Dev, test, and CI clone patterns
- Cost, storage divergence, and pitfalls
- Cheat sheet — zero-copy cloning recipes
- Frequently asked questions
- Practice on PipeCode
1. How zero-copy cloning actually works
A clone copies the manifest of block pointers, not the blocks — copy-on-write pays for divergence only
The one-sentence invariant: zero-copy cloning creates a new table (or schema, or database) whose metadata points at the exact same immutable storage blocks the source already references, so creation is a metadata-only operation that duplicates no data and adds no storage — and the clone only begins to cost storage when a write forks a changed block via copy-on-write, at which point the writer pays for the delta and nothing else. Everything else in this article — Snowflake's CLONE, Databricks' shallow and deep clone, the CI patterns, the cost model — is a consequence of this single mechanism. Get the mechanism and the rest is bookkeeping.
The mechanism in three moving parts.
- Immutable storage blocks. Modern analytical engines never mutate a data block in place. Snowflake stores data as compressed, columnar micro-partitions (roughly 50–500 MB uncompressed each); Delta Lake stores data as Parquet files. When you "update" a row, the engine writes a new block containing the new version and stops referencing the old one — it does not overwrite bytes. This is what makes cloning possible: because blocks are never mutated, two tables can safely share the same block with zero risk of one corrupting the other.
-
The metadata manifest. A table is not its data; it is a list of which blocks belong to it right now. Snowflake keeps this in its cloud-services metadata layer; Delta keeps it in the
_delta_logtransaction log. The manifest is tiny relative to the data — kilobytes to a few megabytes for a table holding terabytes. - The clone = a second manifest. Cloning writes a new manifest that references the same block list. No block is read, copied, or moved. The clone and the source are now two independent tables that happen to share physical storage. Creation time is bounded by manifest size, which is why a 40 TB table and a 40 GB table clone in roughly the same near-instant time.
Copy-on-write — how the clone earns its independence.
- Shared until written. Immediately after cloning, every block is shared. The clone occupies ~0 extra bytes because it owns no unique blocks.
-
A write forks one block. When you
UPDATE,DELETE, orINSERTinto the clone, the engine writes a new block for the changed data and rewrites the clone's manifest to point at the new block instead of the shared one. The source is untouched; its manifest still points at the original block. Only the new block is unique to the clone, and only that block counts against the clone's storage. - Divergence is incremental. Storage attributable to the clone grows exactly in proportion to how much of it you rewrite. Clone a table and read-only it forever → ~0 storage. Clone a table and rewrite half of it → roughly half the table's size in new blocks. This linear-in-divergence cost is the whole economic story.
The four axes interviewers probe.
- Creation time. Metadata-bound, effectively O(manifest) not O(data). A clone of any size table completes in seconds. Weak candidates say "it copies the data in the background"; it does not.
- Storage at t0. Near-zero. The clone adds only the small metadata for its own manifest. This is the "zero-copy" in the name.
- Independence. Full logical isolation. Writes to the clone never reach the source and vice versa. They are two separate tables; they merely share physical blocks read-only.
- Divergence cost over time. The one non-zero axis. As either side rewrites blocks, unique blocks accumulate and storage grows. Retention features (Time Travel, Fail-safe) amplify this by holding old blocks longer.
What interviewers listen for.
- Do you say "it copies pointers, not bytes" in the first sentence? — required answer.
- Do you name copy-on-write as the divergence mechanism, not "it copies changed rows"? — senior signal.
- Do you note that blocks are immutable, which is why sharing is safe? — senior signal.
- Do you say storage is "zero at creation, linear in divergence" rather than "free"? — required nuance.
- Do you connect cloning to Time Travel / versioning as the same underlying block-manifest machinery? — senior signal.
Worked example — pointers vs bytes, in block accounting
Detailed explanation. The fastest way to internalise zero-copy cloning is to account for blocks explicitly rather than think in rows. Model a small table as a set of blocks, clone it, and track which blocks each manifest points at. The accounting makes it obvious why creation is free and why divergence is not.
-
Source table.
orders, physically stored as 10 blocksB1..B10, 200 MB each → 2 GB total. -
The clone.
orders_dev, created byCLONE. - The question at each step. How many unique blocks does each table own, and what is the total physical storage?
Question. After cloning orders to orders_dev and making no writes, how much physical storage does orders_dev add?
Input.
| Object | Manifest points at | Unique blocks owned | Physical bytes attributable |
|---|---|---|---|
| orders (source) | B1..B10 | 0 (all shared) | shared |
| orders_dev (clone) | B1..B10 | 0 (all shared) | ~0 (metadata only) |
| Total physical blocks on disk | B1..B10 | 10 | 2 GB |
Code.
-- Snowflake: clone the whole table (metadata-only, instant)
CREATE TABLE analytics.orders_dev CLONE analytics.orders;
-- Both tables now report the same row count and the same size,
-- but the account stores only ONE physical copy of the data.
SELECT 'orders' AS tbl, COUNT(*) AS rows FROM analytics.orders
UNION ALL
SELECT 'orders_dev' AS tbl, COUNT(*) AS rows FROM analytics.orders_dev;
Step-by-step explanation.
-
CREATE TABLE ... CLONEwrites a new manifest fororders_devthat lists blocksB1..B10— the same physical blocksordersreferences. No block is read or copied; the operation touches only metadata. - Both tables now report 2 GB in a naive per-table size view, because each logically contains all 10 blocks. But the account's physical footprint is still 10 blocks — the shared blocks are counted once on disk, not twice.
-
orders_devowns zero unique blocks, so its attributable storage is ~0 (just the kilobytes of its manifest). This is why a clone is called "zero-copy": at creation it adds no data bytes. - Reading from
orders_devreads the shared blocks directly — the clone is a first-class table, not a view or a snapshot you have to "materialise." Query performance is identical to the source. - The two tables are now logically independent. Dropping
orders_devsimply deletes its manifest; because it owns no unique blocks, nothing on disk is freed (the blocks are still referenced byorders). Droppingorderswould not delete blocks still referenced byorders_dev— reference counting protects both.
Output.
| Metric | Value |
|---|---|
| Clone creation time | seconds (metadata only) |
| Physical blocks before clone | 10 (2 GB) |
| Physical blocks after clone | 10 (2 GB) |
| Unique blocks owned by clone | 0 |
| Storage added by clone | ~0 |
Rule of thumb. Reason about clones in blocks, not rows. A clone adds a manifest, not data; its storage is the count of blocks it uniquely owns, which starts at zero and grows only when it writes.
Worked example — copy-on-write divergence, block by block
Detailed explanation. Now watch the same table diverge. A write to the clone forks exactly the blocks it touches; everything untouched stays shared. Track the unique-block count as writes land — this is the number that ends up on your storage bill.
-
Start.
ordersandorders_devshareB1..B10. -
Write 1. Update rows that live in
B3only → engine writes a new blockB11for the clone; clone manifest swapsB3 → B11. -
Write 2. Delete rows spread across
B5andB6→ engine writesB12(the survivors of B5+B6 compacted); clone manifest dropsB5, B6, addsB12.
Question. After the two writes, how many unique blocks does orders_dev own, and what storage does it add?
Input.
| Event | Blocks touched | New block written | Clone manifest after |
|---|---|---|---|
| clone | none | none | B1..B10 (all shared) |
| update in B3 | B3 | B11 | B1,B2,B11,B4..B10 |
| delete in B5,B6 | B5,B6 | B12 | B1,B2,B11,B4,B12,B7..B10 |
Code.
-- Diverge the clone; the source is never touched
UPDATE analytics.orders_dev
SET status = 'test_shipped'
WHERE order_id BETWEEN 4001 AND 4200; -- rows physically in one block
DELETE FROM analytics.orders_dev
WHERE status = 'cancelled'; -- rows across two blocks
-- The source still sees its original data, unchanged
SELECT status, COUNT(*) FROM analytics.orders GROUP BY status;
SELECT status, COUNT(*) FROM analytics.orders_dev GROUP BY status;
Step-by-step explanation.
- The
UPDATEtouches rows that happen to live in blockB3. Because blocks are immutable, the engine cannot editB3in place — it writes a brand-new blockB11holding the updated version of those rows and repoints the clone's manifest fromB3toB11.ordersstill points atB3; it is completely unaffected. - After write 1, the clone owns exactly one unique block (
B11). Storage attributable to the clone is now one block (~200 MB), not the whole 2 GB. You paid for the delta, nothing more. - The
DELETEremoves rows spread acrossB5andB6. The engine writes a compacted blockB12containing only the surviving rows ofB5+B6, and repoints the manifest to dropB5, B6and addB12. The clone now owns two unique blocks (B11,B12). - Crucially,
B3,B5, andB6are still referenced byorders, so they remain on disk — the clone's writes did not free them. If the source later stops referencing them too (e.g. it runs its own update), reference counting plus the retention window decides when they can finally be purged. - The clone's total attributable storage is now the size of
B11+B12. BlocksB1, B2, B4, B7..B10are still shared and cost the clone nothing. Divergence, not clone age, drives the bill.
Output.
| Metric | After clone | After write 1 | After write 2 |
|---|---|---|---|
| Clone unique blocks | 0 | 1 (B11) | 2 (B11, B12) |
| Clone storage added | ~0 | ~200 MB | ~400 MB |
| Source blocks changed | 0 | 0 | 0 |
| Source storage change | 0 | 0 | 0 |
Rule of thumb. Copy-on-write means the clone's cost equals the blocks it rewrites, not the blocks it reads. A clone you only read from stays free forever; a clone you rewrite in full ends up the size of the original.
Worked example — when a clone beats a copy, and when it doesn't
Detailed explanation. Zero-copy cloning is not always the right tool. It shines when you need a logically-independent, instantly-available branch that shares most of its data with the source. It is the wrong tool when you need physical isolation across storage accounts, or when the clone will be rewritten so completely that it saves nothing. Walk the decision.
- Great fit. Dev sandbox, CI test data, pre-migration validation, point-in-time analysis — all read-mostly or lightly-mutated, all short-lived, all needing prod-shaped data now.
-
Poor fit. Cross-region disaster recovery (you need bytes in another region, so you need a real copy), a "clone" you will
TRUNCATEand fully reload (no sharing benefit), or handing data to an external party who must not share physical storage with prod.
Question. For four scenarios, decide clone vs physical copy and justify from the block-sharing model.
Input.
| Scenario | Independence needed | Divergence expected | Best choice |
|---|---|---|---|
| Per-developer sandbox | logical | low | clone |
| CI test of a migration | logical | medium (dropped after) | clone |
| Cross-region DR | physical (other region) | n/a | deep copy / replication |
| Full reload of the target | logical | ~100% | copy (clone saves nothing) |
Code.
# A tiny decision helper — clone unless you need physical bytes elsewhere
def should_clone(needs_other_region: bool,
expected_rewrite_fraction: float,
short_lived: bool) -> str:
if needs_other_region:
return "physical copy / replication (clone shares storage in-region only)"
if expected_rewrite_fraction >= 0.9:
return "physical copy (near-total divergence => clone saves ~0 storage)"
return "zero-copy clone (instant, cheap, logically independent)"
print(should_clone(False, 0.05, True)) # dev sandbox
print(should_clone(False, 0.30, True)) # CI migration test
print(should_clone(True, 0.00, False)) # cross-region DR
print(should_clone(False, 1.00, False)) # full reload
Step-by-step explanation.
- The dev sandbox rewrites almost nothing (developers read prod-shaped data and tweak a few rows), so the clone stays near-free and is available instantly — the textbook fit.
- The CI migration test may rewrite a moderate fraction while the migration runs, but the clone is dropped at the end of the job, so even the diverged blocks are transient. Clone wins decisively over a physical copy that would take hours to stage.
- Cross-region DR needs the bytes to exist in a second region. A clone shares blocks only within the same account/region storage; it provides no physical redundancy. You need real replication or a deep copy that writes independent bytes to the other region.
- A target you will fully reload (
TRUNCATE+INSERTeverything) will diverge to ~100%, so the clone ends up owning a full second copy of the data anyway. The sharing benefit is zero; you might as wellCREATE TABLE AS SELECT. Clone still creates faster, but there is no storage advantage. - The general rule falls out of the block model: clone whenever you want logical isolation with low-to-moderate divergence in the same region; copy when you need physical bytes elsewhere or you will rewrite everything.
Output.
| Scenario | Decision | Why (block model) |
|---|---|---|
| Dev sandbox | clone | low divergence; shares nearly all blocks |
| CI migration test | clone | transient divergence; dropped after |
| Cross-region DR | physical copy | needs bytes in another region |
| Full reload | copy | ~100% divergence; no sharing benefit |
Rule of thumb. Clone for logically-independent, same-region, read-mostly branches; physically copy when you need bytes in another region or you will rewrite the whole target. The break-even is divergence: below it, cloning is nearly free; near total, it saves nothing.
Data engineering interview question on clone mechanics
A senior interviewer often opens with: "Explain to a skeptical product manager why cloning our 40-terabyte events table for every developer is both instant and essentially free at creation, but why the finance team still saw the storage bill creep up last quarter. Then tell me the one operational habit that keeps that creep under control."
Solution Using the block-manifest + copy-on-write model with a divergence budget
-- 1. The clone itself is a metadata operation — instant regardless of size
CREATE TABLE dev.events_alice CLONE prod.events; -- completes in seconds
-- 2. At t0 the clone owns zero unique micro-partitions.
-- Snowflake exposes per-table physical bytes here:
SELECT table_name,
active_bytes, -- bytes in currently-referenced partitions
time_travel_bytes, -- bytes held for Time Travel
failsafe_bytes, -- bytes held for Fail-safe
clone_group_id -- tables sharing a clone lineage
FROM snowflake.account_usage.table_storage_metrics
WHERE table_name IN ('EVENTS', 'EVENTS_ALICE');
# 3. Model the quarter: divergence, not clone count, drives the bill.
TABLE_TB = 40
DEV_CLONES = 12
REWRITE_FRACTION = 0.08 # each dev rewrites ~8% of their clone over the quarter
RETENTION_MULT = 1.5 # Time Travel + Fail-safe hold old blocks ~1.5x longer
net_new_tb = DEV_CLONES * TABLE_TB * REWRITE_FRACTION * RETENTION_MULT
print(f"Storage from 12 dev clones: {net_new_tb:.1f} TB net-new (vs {DEV_CLONES*TABLE_TB} TB if copied)")
# -> Storage from 12 dev clones: 57.6 TB net-new (vs 480 TB if physically copied)
Step-by-step trace.
| Step | What happens | Storage effect |
|---|---|---|
| Clone created | new manifest points at source micro-partitions | ~0 net-new |
| Dev reads only | queries hit shared blocks | ~0 net-new |
| Dev updates 8% | copy-on-write forks changed blocks | +8% of clone size |
| Retention window | forked/old blocks held by Time Travel + Fail-safe | ×~1.5 amplification |
| Clone dropped | manifest deleted; unique blocks eligible to purge | freed after retention |
After the walkthrough the PM sees the two truths that feel contradictory but are not: creation is free because it duplicates no bytes, and the bill still grew because twelve developers each rewrote a slice of their clone, and those rewritten blocks — plus the old versions held for the retention window — are real net-new storage. The fix is not "stop cloning"; it is "drop clones when done and keep divergence low."
Output:
| Metric | Physical copy of 12 clones | Zero-copy clone reality |
|---|---|---|
| Creation time | hours each | seconds each |
| Storage at t0 | 480 TB | ~0 TB |
| Storage after a quarter | 480 TB+ | ~58 TB (divergence + retention) |
| Cost driver | full data size | rewrite fraction × retention |
| Cleanup lever | delete tables | drop clones + short retention |
Why this works — concept by concept:
-
Metadata clone — the clone is a second manifest of block pointers, so
CREATE ... CLONEis bounded by metadata size, not data size. That is why a 40 TB and a 40 GB table clone in the same few seconds. - Immutable blocks — micro-partitions and Parquet files are never mutated in place, so two tables can share a block with zero risk. Sharing safety is a consequence of immutability, not a lock.
- Copy-on-write — a write forks only the touched blocks into new copies the writer owns; untouched blocks stay shared. The clone's bill equals the blocks it rewrites.
- Retention amplification — Time Travel and Fail-safe keep old block versions for a window, so divergence costs more than the naive delta until the window rolls off. This is the usual reason a bill "creeps."
- Cost — creation is O(metadata) and ~0 bytes; ongoing storage is O(rewritten blocks × retention). Dropping idle clones and keeping retention short returns the cost to near-zero. Net: instant and free at t0, linear-in-divergence thereafter.
Database
Topic — database
Database internals and storage-model problems
2. Snowflake CLONE — databases, schemas, tables
CREATE <object> CLONE is a metadata operation that can branch a table, a schema, or an entire database — from now or from any Time-Travel point
The mental model in one line: snowflake clone uses the CREATE <object> CLONE <source> syntax to produce an instant, independent copy of a table, schema, or database by duplicating the micro-partition metadata and nothing else — and because Snowflake couples cloning with Time Travel, you can clone the object as it existed at a past timestamp, a past query offset, or immediately before a specific statement, which turns "clone" into "branch from any recent point in history." Every Snowflake data engineer uses this daily for dev/test; the depth of your answer is what separates a fluent one from a shallow one.
The syntax surface — three granularities.
-
Table.
CREATE TABLE dev.orders CLONE prod.orders;— clones one table's micro-partition set. Instant regardless of size. -
Schema.
CREATE SCHEMA dev.sales CLONE prod.sales;— clones every table, view, sequence, and (with the right options) stage inside the schema in one statement. Each child table is itself a zero-copy clone. -
Database.
CREATE DATABASE dev CLONE prod;— clones the whole database: all schemas and all their objects, recursively. This is the "give me a full copy of production for staging" button, and it still adds ~0 storage at creation.
What Time Travel adds — branch from the past.
-
AT (TIMESTAMP => ...). Clone the object as of a wall-clock time within the retention window:CLONE prod.orders AT (TIMESTAMP => '2026-09-04 08:00:00'::timestamp_tz). -
AT (OFFSET => -N). Clone as of N seconds ago:AT (OFFSET => -3600)= one hour back. -
BEFORE (STATEMENT => '<query_id>'). Clone as the object existed immediately before a specific statement executed — the "undo that badDELETE" button. You find the query id inQUERY_HISTORY. -
The retention floor. Time-Travel clones only reach back as far as the source object's
DATA_RETENTION_TIME_IN_DAYS(1 day on Standard by default; up to 90 on Enterprise). Beyond that, the old micro-partitions are gone.
What is and isn't carried by a clone.
- Carried. Table structure, data (as shared pointers), clustering keys, most table-level settings, and — for schema/database clones — the child objects recursively.
-
Grants: opt-in. By default a cloned table does not inherit the source's privileges. Add
COPY GRANTSto carry them:CREATE TABLE dev.orders CLONE prod.orders COPY GRANTS;. Forgetting this is the single most common clone surprise. -
Not carried. Load history (the clone can re-
COPYa file the source already loaded), and external named stages are referenced, not duplicated. - Pipes, streams, tasks. A database/schema clone clones contained pipes, streams, and tasks, but they are created suspended and streams start with a fresh offset — you do not want a cloned pipe silently ingesting into a dev database.
Common interview probes on Snowflake CLONE.
- "How long does it take to clone a 10 TB table?" — seconds; it is metadata-only.
- "How do you clone production as it was before a bad delete?" —
BEFORE (STATEMENT => '<query_id>'). - "Do grants come along?" — no, unless you add
COPY GRANTS. - "What happens to tasks/pipes in a cloned database?" — cloned suspended; re-enable deliberately.
Worked example — instant table clone and an independence proof
Detailed explanation. The canonical Snowflake move: clone a production table into a dev schema, prove the two are logically independent by writing to each, and confirm the account stored only one physical copy at creation. Walk it end to end.
-
Source.
prod.orders, ~2 TB. -
Clone.
dev.orders. - Proof. Write to the clone, verify the source is unchanged; write to the source, verify the clone is unchanged.
Question. Clone prod.orders to dev.orders, then demonstrate that writes on each side are invisible to the other.
Input.
| Step | Action | Expected effect |
|---|---|---|
| 1 | CLONE prod.orders |
dev.orders appears instantly, ~0 storage |
| 2 | update dev.orders | only dev sees the change |
| 3 | insert into prod.orders | only prod sees the change |
Code.
-- 1. Instant, zero-copy clone into a dev schema
CREATE TABLE dev.orders CLONE prod.orders;
-- 2. Mutate the clone only
UPDATE dev.orders SET status = 'DEV_TEST' WHERE order_id = 42;
-- 3. Mutate the source only
INSERT INTO prod.orders (order_id, customer_id, total_cents, status)
VALUES (999999, 7, 1500, 'pending');
-- 4. Independence checks
SELECT status FROM dev.orders WHERE order_id = 42; -- 'DEV_TEST'
SELECT status FROM prod.orders WHERE order_id = 42; -- original value
SELECT COUNT(*) FROM dev.orders WHERE order_id = 999999; -- 0 (insert not visible)
SELECT COUNT(*) FROM prod.orders WHERE order_id = 999999; -- 1
Step-by-step explanation.
-
CREATE TABLE dev.orders CLONE prod.orderswrites a new manifest referencingprod.orders's micro-partitions. It returns in seconds even at 2 TB because no data is read. - The
UPDATEondev.ordersforks the micro-partition holdingorder_id = 42into a new partition owned bydev.orders.prod.ordersstill points at the original partition and is unchanged. - The
INSERTintoprod.orderswrites a new micro-partition owned byprod.orders.dev.orders's manifest was fixed at clone time, so it never learns about the new row — the clone is a branch, not a live replica. - The four checks confirm both directions of isolation: the dev update is invisible to prod, and the prod insert is invisible to dev. This is the property that makes clones safe sandboxes — nothing you do in dev can reach production.
- At creation the account stored one physical copy; after the two writes it stores that copy plus two small forked partitions (one per side). The 2 TB was never duplicated.
Output.
| Query | Result | Interpretation |
|---|---|---|
| dev.orders status of 42 | DEV_TEST | clone diverged |
| prod.orders status of 42 | original | source untouched |
| dev.orders has 999999 | no | prod insert invisible to clone |
| prod.orders has 999999 | yes | source moved on independently |
Rule of thumb. A Snowflake clone is a branch point, not a live mirror. After the clone, the two tables evolve independently; use it when you want a frozen, prod-shaped starting line you can safely trash.
Worked example — clone an entire database at a Time-Travel timestamp
Detailed explanation. The most powerful Snowflake clone is a whole-database clone pinned to a past moment — for example, "give me all of production exactly as it was at 08:00 this morning, before the bad backfill." One statement rebuilds a complete staging environment at a point in time.
-
Goal. Reproduce a data incident by branching the entire
proddatabase to just before the backfill job. -
Anchor. A timestamp (or the
query_idof the offending statement). -
Result. A full
debugdatabase, every schema and table pinned to 08:00, ~0 storage.
Question. Clone the whole prod database as of 2026-09-04 08:00:00 into a debug database for incident analysis.
Input.
| Parameter | Value |
|---|---|
| Source | database prod
|
| Anchor | TIMESTAMP 2026-09-04 08:00:00 |
| Target | database debug
|
| Retention required | source retention ≥ time since 08:00 |
Code.
-- 1. Confirm the source has enough Time-Travel retention to reach 08:00
SHOW PARAMETERS LIKE 'DATA_RETENTION_TIME_IN_DAYS' IN DATABASE prod;
-- 2. Clone the ENTIRE database as of a past timestamp (one statement)
CREATE DATABASE debug
CLONE prod
AT (TIMESTAMP => '2026-09-04 08:00:00'::TIMESTAMP_TZ);
-- 3. Every schema + table now exists in `debug`, pinned to 08:00
SELECT table_schema, table_name, row_count
FROM debug.information_schema.tables
ORDER BY table_schema, table_name;
-- 4. Alternatively, branch before a specific offending statement
CREATE DATABASE debug2
CLONE prod
BEFORE (STATEMENT => '01b2c3d4-0000-abcd-0000-000000001234');
Step-by-step explanation.
- Before cloning at a timestamp, confirm the source's
DATA_RETENTION_TIME_IN_DAYScovers the gap between now and 08:00. If retention is 1 day and 08:00 was yesterday, the micro-partitions from that moment are gone and the clone will fail or land at the retention floor. -
CREATE DATABASE debug CLONE prod AT (TIMESTAMP => ...)recursively clones every schema and every table, each pinned to its state at 08:00. The whole environment materialises in seconds and adds ~0 storage, because it shares the historical micro-partitions Time Travel is already retaining. - Querying
debug.information_schema.tablesshows the full object graph is present. Analysts can now investigate the incident against a faithful 08:00 snapshot without touching production and without waiting for a restore. - The
BEFORE (STATEMENT => '<query_id>')variant is the surgical version: branch the database as it existed immediately before the exact statement that caused the incident, which you look up inQUERY_HISTORY. This is the cleanest way to isolate "what did the data look like right before this ran." - When the investigation ends,
DROP DATABASE debugremoves the manifests; because the clone owned almost no unique partitions, little or nothing is freed immediately — the shared historical partitions remain governed by the source's retention.
Output.
| Aspect | Result |
|---|---|
| Objects cloned | every schema + table in prod |
| Creation time | seconds |
| Storage added | ~0 (shares retained history) |
| Point-in-time fidelity | exact as of 08:00 / pre-statement |
| Teardown | DROP DATABASE debug |
Rule of thumb. Whole-database Time-Travel clones are the fastest incident-response and staging tool Snowflake offers — but they are bounded by the source's retention window. If you need to branch from last week, you must have set retention to cover it in advance.
Worked example — carrying grants and layering governance with COPY GRANTS
Detailed explanation. A clone that silently drops all privileges is a footgun: your dev schema exists but no one can query it, or worse, you re-grant broadly and expose data you meant to mask. COPY GRANTS carries the source's privileges; a masking policy layered on the clone protects PII for non-prod consumers.
-
Problem.
CREATE TABLE dev.customers CLONE prod.customersproduces a table only its owner can see. -
Fix 1.
COPY GRANTSreplicates the source's grants onto the clone. - Fix 2. Apply a masking policy on the clone so analysts see redacted PII in the sandbox.
Question. Clone prod.customers into dev.customers, carry the source grants, and mask the email column for the analyst role.
Input.
| Requirement | Mechanism |
|---|---|
| Same access as prod | COPY GRANTS |
| Redact email in dev |
MASKING POLICY on clone |
| Analyst role sees masked | policy returns *** unless role is admin |
Code.
-- 1. Clone WITH grants so existing roles keep access
CREATE TABLE dev.customers CLONE prod.customers COPY GRANTS;
-- 2. Define a masking policy (once)
CREATE MASKING POLICY dev.mask_email AS (val STRING) RETURNS STRING ->
CASE
WHEN CURRENT_ROLE() IN ('DATA_ADMIN') THEN val
ELSE REGEXP_REPLACE(val, '.+@', '****@')
END;
-- 3. Attach the policy to the clone's email column (dev only)
ALTER TABLE dev.customers
MODIFY COLUMN email SET MASKING POLICY dev.mask_email;
-- 4. Verify: an analyst sees masked email in dev, real email never leaves prod
SELECT customer_id, email FROM dev.customers LIMIT 5; -- ****@domain.com
Step-by-step explanation.
-
COPY GRANTSon the clone copies the privilege set fromprod.customers— so the roles that couldSELECTin production canSELECTthe dev clone immediately, with no manual re-granting. Without it, the clone is owner-only and every consumer is locked out. - The masking policy is a schema-level object evaluated at query time. It returns the real value only for
DATA_ADMINand a redacted value for everyone else — the classic column-level protection. - Attaching the policy to the clone's
emailcolumn applies masking only in dev. Production's ownemailcolumn keeps whatever policy prod already had; the clone's governance is independent because it is a separate object. - When an analyst queries
dev.customers, the policy rewritesemailto****@domain.com. The clone is now safe to hand to non-prod consumers: real data shape, real row counts, but PII redacted. - This is the standard "safe staging from prod" recipe: clone with grants for zero-friction access, then layer masking so the convenience of prod-shaped data does not become a compliance incident.
Output.
| Consumer | Sees email as | Access source |
|---|---|---|
| DATA_ADMIN in dev | real value | masking bypass |
| Analyst in dev | ****@domain.com | masking policy |
| Any role in prod | governed by prod policy | independent object |
Rule of thumb. Always decide grants explicitly: add COPY GRANTS when the clone should inherit access, and layer a masking policy before non-prod consumers touch cloned PII. A clone copies data pointers, not your data-governance intent.
SQL interview question on Snowflake cloning
A senior interviewer might ask: "The nightly backfill corrupted prod.orders and prod.order_items at around 02:15. You have the offending query_id. Rebuild a full, queryable staging copy of the affected schema as it existed just before that statement, carry the existing grants so the on-call team can query it, and explain what it costs in storage and how long it takes."
Solution Using a Time-Travel schema clone with COPY GRANTS
-- 1. Find the offending statement's query_id (if not already known)
SELECT query_id, query_text, start_time
FROM snowflake.account_usage.query_history
WHERE query_text ILIKE '%backfill%orders%'
AND start_time BETWEEN '2026-09-05 02:00' AND '2026-09-05 02:30'
ORDER BY start_time;
-- 2. Clone the whole schema as it existed BEFORE that statement, with grants
CREATE SCHEMA staging.orders_recover
CLONE prod.sales
BEFORE (STATEMENT => '01b2c3d4-0000-abcd-0000-000000009999')
COPY GRANTS;
-- 3. Confirm the recovered data is intact and pre-corruption
SELECT COUNT(*) AS good_rows,
MIN(created_at), MAX(created_at)
FROM staging.orders_recover.orders;
-- 4. (Optional) surgically restore just the damaged rows back into prod
MERGE INTO prod.orders AS tgt
USING staging.orders_recover.orders AS src
ON tgt.order_id = src.order_id
WHEN MATCHED AND tgt.status = 'CORRUPT' THEN
UPDATE SET tgt.status = src.status, tgt.total_cents = src.total_cents;
Step-by-step trace.
| Step | Action | Result |
|---|---|---|
| Locate statement | query_history lookup | exact query_id of the backfill |
| Clone schema | BEFORE (STATEMENT => ...) + COPY GRANTS | pre-corruption schema, grants intact |
| Verify | row counts + timestamp bounds | data confirmed good |
| Repair | MERGE recovered rows into prod | surgical fix, no full restore |
| Teardown | DROP SCHEMA when done | manifests removed |
After the clone lands — in seconds, at ~0 storage — the on-call team has a fully queryable, correctly-permissioned copy of the sales schema exactly as it stood before 02:15. They confirm the data is clean, then either repoint downstream at the recovered schema or MERGE the good rows back into production. No RESTORE, no backup tape, no hours of waiting.
Output:
| Metric | Value |
|---|---|
| Recovery object |
staging.orders_recover schema |
| Point in time | immediately before the backfill statement |
| Creation time | seconds (metadata only) |
| Storage added | ~0 (shares retained micro-partitions) |
| Access | inherited via COPY GRANTS |
| Repair | targeted MERGE, prod stays online |
Why this works — concept by concept:
-
Time Travel as clone anchor —
BEFORE (STATEMENT => query_id)branches the schema from the exact micro-partitions that existed before the bad statement, because Snowflake retains those partitions for the retention window. Clone + Time Travel = point-in-time recovery without a restore. - Schema-level granularity — cloning the schema (not just one table) rebuilds every affected object at once, so cross-table referential state is consistent at the chosen instant.
- COPY GRANTS — carries the source privileges onto the recovered schema, so the on-call team queries it immediately instead of filing an access ticket mid-incident.
-
Surgical MERGE — because the clone is a real, independent table, you can
MERGEjust the corrupted rows back into live production without an outage, rather than swapping whole tables. - Cost — creation is O(metadata), storage is ~0 at t0 and grows only if you rewrite the recovered schema. The bounded cost is the source's retention window; the eliminated cost is a multi-hour restore and the downtime around it. O(seconds) recovery versus O(hours).
Snowflake
Topic — database
Database problems on Snowflake and warehouses
3. Databricks shallow vs deep clone
SHALLOW CLONE copies the Delta log and references the source files; DEEP CLONE copies the data too — one is a pointer, the other is a real copy
The mental model in one line: databricks shallow clone writes a new Delta transaction log that references the source's existing Parquet data files, giving you an instant, near-free, logically-independent table that physically depends on the source, while DEEP CLONE additionally copies the underlying Parquet files into the clone's own location, giving you a fully self-contained copy that costs full storage and takes real time — and both can be pinned to a past VERSION/TIMESTAMP and refreshed incrementally. Choosing between them is a question of one axis: do you need physical independence from the source, or just logical independence?
The Delta anatomy that makes clones work.
- Data files. A Delta table's rows live in immutable Parquet files in cloud storage. Like micro-partitions, they are never edited in place — updates write new files and mark old ones removed in the log.
-
The transaction log (
_delta_log). A sequence of JSON commit files (plus periodic checkpoints) that records which Parquet files are live at each version. The log is the manifest; the Parquet files are the blocks. -
A clone = a new log. Cloning writes a fresh
_delta_logfor the target. What differs is whether that log points at the source's Parquet files (shallow) or at freshly-copied Parquet files in the target's location (deep).
SHALLOW CLONE — the metadata clone.
- What it does. Copies the log entries (the list of live files) into the clone's location; the entries still reference the source's Parquet paths. No data file is copied.
- Cost & speed. Near-zero storage, seconds to create — the Databricks analogue of Snowflake's zero-copy clone.
-
The dependency. Because the clone points at the source's files, it is not self-contained. If the source runs
VACUUMand physically deletes a Parquet file the shallow clone still references, the clone breaks (FileNotFoundException). This is the single most important shallow-clone caveat. - Best for. Short-lived test tables, CI fixtures, quick experiments — anything you will drop before the source vacuums away the files it depends on.
DEEP CLONE — the full, independent copy.
- What it does. Copies both the log and all live Parquet files into the clone's own storage location.
- Cost & speed. Full storage footprint; time proportional to data size (though it copies files, not re-processes rows).
-
The independence. Fully self-contained. Source
VACUUMcannot touch it; you can even point it at a different storage location or region for a real backup. -
Incremental sync. Re-running
DEEP CLONEfrom the same source into the same target copies only the files that changed since the last clone — an efficient, restartable snapshot-sync you can schedule nightly.
Versioning and incremental refresh.
-
Pin to a version/time.
SHALLOW CLONE src VERSION AS OF 42or... TIMESTAMP AS OF '2026-09-04'branches from a past Delta version — the same time-travel idea as Snowflake. -
CREATE OR REPLACE ... DEEP CLONE. Re-points/refreshes the clone to the source's current state, copying only new files. This is how you keep a deep-clone DR copy current.
Common interview probes on Databricks clones.
- "Shallow vs deep — what's the difference?" — shallow references source files (instant, dependent); deep copies files (independent, full cost).
- "What breaks a shallow clone?" — the source
VACUUMing files the clone still references. - "How do you keep a deep clone up to date cheaply?" — re-run
DEEP CLONE; it copies only changed files. - "Can you clone a past version?" — yes,
VERSION AS OF/TIMESTAMP AS OF.
Worked example — a shallow clone for a fast, disposable test run
Detailed explanation. The everyday Databricks move: shallow-clone a production Delta table into a scratch schema, run a test workload that mutates it, assert on results, then drop it — all before the source's next VACUUM. Instant and near-free.
-
Source.
prod.eventsDelta table, 5 TB. -
Clone.
test.events_ci, shallow. - Lifecycle. Create → mutate → assert → drop, inside one job.
Question. Shallow-clone prod.events, run a transformation that rewrites a partition, verify the source is untouched, and drop the clone.
Input.
| Step | Action | Storage effect |
|---|---|---|
| create | SHALLOW CLONE | ~0 (log only) |
| transform | rewrite one partition | new files owned by clone |
| assert | compare counts | source unchanged |
| drop | DROP TABLE | clone log removed |
Code.
-- 1. Instant shallow clone into a scratch schema
CREATE TABLE test.events_ci
SHALLOW CLONE prod.events;
-- 2. Mutate the clone (writes NEW files owned by the clone; source files untouched)
UPDATE test.events_ci
SET event_type = 'synthetic'
WHERE event_date = '2026-09-01';
-- 3. Independence check
SELECT COUNT(*) FROM test.events_ci WHERE event_type = 'synthetic'; -- > 0
SELECT COUNT(*) FROM prod.events WHERE event_type = 'synthetic'; -- 0
-- 4. Tear down after the test
DROP TABLE test.events_ci;
# The same lifecycle inside a job (PySpark / Databricks)
spark.sql("CREATE TABLE test.events_ci SHALLOW CLONE prod.events")
spark.sql("UPDATE test.events_ci SET event_type='synthetic' WHERE event_date='2026-09-01'")
assert spark.table("prod.events").filter("event_type='synthetic'").count() == 0
spark.sql("DROP TABLE test.events_ci") # drop BEFORE prod VACUUM to avoid dependency
Step-by-step explanation.
-
CREATE TABLE ... SHALLOW CLONEwrites a new_delta_logfortest.events_ciwhose entries referenceprod.events's existing Parquet files. It completes in seconds and adds essentially no storage — the clone owns a log, not data. - The
UPDATErewrites the2026-09-01partition. Delta writes new Parquet files (owned by the clone's location) for the changed partition and records the swap in the clone's log. The source's files for that partition are still referenced only by the source. - The independence check confirms the mutation is visible in the clone and absent from the source — logical isolation holds even though the two tables physically share the untouched files.
- Running the whole thing in a job, the clone is created, mutated, asserted, and dropped within the job's lifetime. Because it is dropped quickly, there is no window in which the source could
VACUUMaway a file the clone still needs. - The reason this is safe only when short-lived: the clone references source files. If prod ran
VACUUM prod.eventswhile the clone still pointed at those files, the clone would throwFileNotFoundExceptionon read. Drop-before-vacuum is the discipline that keeps shallow clones safe.
Output.
| Query | Result |
|---|---|
| clone rows with synthetic | > 0 |
| source rows with synthetic | 0 |
| clone creation time | seconds |
| storage added by clone | ~0 (until it rewrote one partition) |
Rule of thumb. Use shallow clones for short-lived, disposable test tables and drop them within the job. Never keep a shallow clone alive across the source's VACUUM schedule — its files can vanish underneath it.
Worked example — a deep clone as an independent, restartable backup
Detailed explanation. When you need a copy that survives the source being vacuumed, moved, or deleted — a backup, a DR copy, a hand-off to another team — use DEEP CLONE. It copies the Parquet files into the clone's own location, so it stands alone. And because re-running it copies only changed files, it doubles as an incremental snapshot.
-
Goal. A self-contained nightly backup of
prod.transactionsin a separate storage path. - First run. Copies all live files (full cost, real time).
- Later runs. Copy only files changed since last run (cheap, restartable).
Question. Create a deep-clone backup of prod.transactions to a backup location, then refresh it the next night copying only the delta.
Input.
| Run | Command | Files copied |
|---|---|---|
| night 1 | CREATE ... DEEP CLONE | all live files |
| night 2 | CREATE OR REPLACE ... DEEP CLONE | only changed files |
| after source VACUUM | (independent) | backup unaffected |
Code.
-- Night 1 — full deep clone into an independent backup location
CREATE TABLE backup.transactions
DEEP CLONE prod.transactions
LOCATION 's3://company-backups/delta/transactions';
-- Night 2..N — incremental refresh: copies ONLY files changed since last clone
CREATE OR REPLACE TABLE backup.transactions
DEEP CLONE prod.transactions
LOCATION 's3://company-backups/delta/transactions';
-- The backup is self-contained: source VACUUM cannot touch it
VACUUM prod.transactions RETAIN 168 HOURS; -- backup still fully readable
SELECT COUNT(*) FROM backup.transactions; -- works regardless of source state
Step-by-step explanation.
- The night-1
DEEP CLONEcopies every live Parquet file ofprod.transactionsintos3://company-backups/...and writes a fresh log there. This takes real time and full storage, because it is a genuine physical copy — that is the price of independence. - The night-2
CREATE OR REPLACE ... DEEP CLONEcompares the source's current live files against what the backup already holds and copies only the new or changed files. Unchanged files are skipped, so the incremental refresh is far cheaper than a full copy and is restartable if it fails midway. - Because the backup owns its own Parquet files, it is completely decoupled from the source. Running
VACUUM prod.transactionsdeletes tombstoned files in the source; the backup's files live in a different location and are untouched. - The final
SELECTagainstbackup.transactionsworks no matter what happens to prod — the source could be dropped entirely and the backup would still read. This is exactly what a shallow clone cannot promise. - This pattern gives you a scheduled, incremental, self-contained snapshot with one idempotent statement per night — the Databricks equivalent of a managed backup, built from clone primitives.
Output.
| Aspect | Shallow clone | Deep clone |
|---|---|---|
| Data files | referenced from source | copied into clone |
| Storage | ~0 | full |
| Survives source VACUUM | no | yes |
| Refresh cost | n/a (re-clone) | only changed files |
| Use | disposable test | backup / DR / hand-off |
Rule of thumb. Deep-clone anything that must outlive the source or live in another location; the incremental CREATE OR REPLACE ... DEEP CLONE turns it into a cheap nightly snapshot that copies only the delta.
Worked example — the VACUUM hazard and how to avoid it
Detailed explanation. The classic shallow-clone incident: a shallow clone kept alive for days, then the source's scheduled VACUUM physically deletes Parquet files the clone still references, and the clone starts throwing FileNotFoundException. Understand the failure, then apply the three defenses.
-
The setup.
test.long_livedis a shallow clone ofprod.bigcreated last week and never dropped. -
The trigger.
VACUUM prod.big RETAIN 168 HOURSdeletes files older than 7 days that are no longer live in the source. - The break. Some of those deleted files are still referenced by the shallow clone's log → reads fail.
Question. Diagnose why the shallow clone broke and give three ways to prevent it.
Input.
| Symptom | Cause | Fix |
|---|---|---|
| FileNotFoundException on clone | source VACUUM removed referenced files | don't keep shallow clones long-lived |
| Clone worked for days then failed | files aged past RETAIN window | deep clone for longevity |
| Only some reads fail | only vacuumed partitions affected | shorten clone lifetime |
Code.
-- The hazard: a shallow clone that outlives the source's retention
CREATE TABLE test.long_lived SHALLOW CLONE prod.big; -- created last week
-- ... days pass, prod rewrites and ages out old files ...
VACUUM prod.big RETAIN 168 HOURS; -- deletes files > 7 days old, no longer live in prod
-- Reading the clone now may fail if it references deleted files:
SELECT COUNT(*) FROM test.long_lived; -- FileNotFoundException (referenced file gone)
-- Defense 1 — for anything long-lived, DEEP CLONE (self-contained)
CREATE TABLE test.long_lived DEEP CLONE prod.big;
-- Defense 2 — keep shallow clones short-lived and drop them in the same job
-- CREATE ... SHALLOW CLONE ... ; run tests ; DROP TABLE ...
-- Defense 3 — inspect what a table physically depends on before vacuuming
DESCRIBE DETAIL prod.big; -- location, numFiles, sizeInBytes
Step-by-step explanation.
- The shallow clone
test.long_livedreferencesprod.big's Parquet files. As long as those files exist, the clone reads fine — it borrows the source's storage. - Over the week,
prod.bigis rewritten by normal ETL; old file versions become non-live in the source and eligible for vacuuming once they age past theRETAINwindow. -
VACUUM prod.big RETAIN 168 HOURSphysically deletes those aged, non-live files.VACUUMonly considers the source's liveness — it has no idea a shallow clone elsewhere still points at them. - The next read of
test.long_livedtries to open a now-deleted file and throwsFileNotFoundException. The clone is not corrupt in a fixable way; the bytes it depended on are simply gone. - The three defenses map to the three columns: use
DEEP CLONEfor anything that must live long (it owns its files); keep shallow clones short-lived and drop them inside the job that made them; and useDESCRIBE DETAILto understand a table's physical footprint before schedulingVACUUM. Deep for longevity, shallow for disposability — the whole rule in five words.
Output.
| Clone type | Lifetime | VACUUM-safe? | Recommended use |
|---|---|---|---|
| Shallow | minutes–hours | only if dropped first | CI / scratch tests |
| Shallow (long-lived) | days+ | no — breaks on VACUUM | avoid |
| Deep | any | yes (self-contained) | backup / DR / durable dev |
Rule of thumb. Shallow clones are safe only while the source still holds their files — treat them as disposable and drop them before the next VACUUM. If a clone must survive days, make it deep.
Data engineering interview question on Databricks cloning
A senior interviewer might ask: "Design a Databricks strategy that gives (a) a durable, self-contained nightly backup of prod.orders in a separate S3 bucket that survives source VACUUM, and (b) instant, disposable per-test-run tables the CI job branches from the latest orders. Cover the clone types, the incremental refresh, the VACUUM safety, and the teardown."
Solution Using nightly DEEP CLONE for DR plus ephemeral SHALLOW CLONE for CI
-- 1. Durable nightly backup — DEEP CLONE to an independent bucket (incremental)
CREATE OR REPLACE TABLE backup.orders
DEEP CLONE prod.orders
LOCATION 's3://company-dr/delta/orders';
-- First run copies all files; subsequent nightly runs copy only changed files.
-- Self-contained: source VACUUM never affects it.
-- 2. Ephemeral CI test table — SHALLOW CLONE the latest orders, per run
CREATE TABLE ci.orders_run_${CI_RUN_ID}
SHALLOW CLONE prod.orders; -- instant, ~0 storage
-- 3. CI runs its destructive tests against the shallow clone
UPDATE ci.orders_run_${CI_RUN_ID} SET status = 'test' WHERE order_id % 100 = 0;
-- ... assertions ...
-- 4. Tear down the shallow clone at the END of the CI job (before any VACUUM)
DROP TABLE ci.orders_run_${CI_RUN_ID};
-- 5. Source housekeeping runs safely because no long-lived shallow clone depends on it
VACUUM prod.orders RETAIN 168 HOURS;
# CI job wrapper — guarantees teardown even on test failure
run_id = os.environ["CI_RUN_ID"]
tbl = f"ci.orders_run_{run_id}"
spark.sql(f"CREATE TABLE {tbl} SHALLOW CLONE prod.orders")
try:
run_migration_and_tests(tbl) # destructive, against real data shape
finally:
spark.sql(f"DROP TABLE IF EXISTS {tbl}") # always drop the shallow clone
Step-by-step trace.
| Layer | Clone type | Independence | Lifetime |
|---|---|---|---|
| DR backup | DEEP CLONE | self-contained (own files) | permanent, refreshed nightly |
| Incremental refresh | CREATE OR REPLACE DEEP CLONE | copies only changed files | nightly |
| CI test table | SHALLOW CLONE | references source files | one CI run |
| Teardown | DROP in finally block | — | dropped before VACUUM |
| Source VACUUM | independent | unaffected by either clone | scheduled |
After rollout, the DR backup is a genuine, self-contained copy in a separate bucket that a source VACUUM — or even a dropped source — cannot harm, and it refreshes nightly by copying only the day's changed files. The CI job branches an instant shallow clone from the latest orders, runs destructive tests against real data shape, and drops it in a finally block so no shallow clone ever outlives the source's file retention.
Output:
| Concern | Mechanism | Result |
|---|---|---|
| Durable backup | DEEP CLONE to separate bucket | survives source VACUUM/drop |
| Cheap refresh | incremental DEEP CLONE | only changed files copied |
| Instant test data | SHALLOW CLONE per run | seconds, ~0 storage |
| VACUUM safety | drop shallow clone in finally | no dangling file references |
| Isolation | separate objects | CI never touches prod |
Why this works — concept by concept:
- Shallow clone = log copy — it duplicates the Delta transaction log and references the source's Parquet files, so it is instant and near-free but physically dependent on the source. Perfect for disposable CI tables.
-
Deep clone = file copy — it copies the Parquet files into its own location, so it is self-contained and survives source
VACUUM, at the cost of full storage and real copy time. The right tool for backups and DR. -
Incremental deep clone —
CREATE OR REPLACE ... DEEP CLONEcopies only files changed since the last run, turning a full backup into a cheap nightly snapshot-sync. -
Teardown discipline — dropping the shallow clone in a
finallyblock guarantees it never outlives the source's file retention, which is the only thing that can break a shallow clone. - Cost — shallow: O(log), ~0 storage, seconds; deep: O(data) first run then O(changed files) per refresh. The DR copy pays full storage once and delta thereafter; the CI clones pay nothing and vanish. Right tool per lifetime.
Databricks
Topic — data-processing
Data-processing problems on Delta Lake and lakehouse
4. Dev, test, and CI clone patterns
Branch production in seconds, run against real data shape, drop when done — the operational payoff of zero-copy cloning
The mental model in one line: the practical value of zero-copy cloning is that it makes prod-shaped dev/test data a per-developer, per-pull-request, per-job commodity — you clone production (or a masked derivative of it) into an isolated sandbox in seconds, iterate or run automated tests against real row counts and real data distributions, and drop the sandbox at the end, so every engineer and every CI run gets a faithful copy of production without ever copying its bytes or touching the live tables. This is where cloning stops being a database feature and becomes a workflow.
The four patterns that cover almost every use.
-
Per-developer sandbox. Each engineer clones prod (or a masked staging DB) into
dev_<name>, works against real data shape, and refreshes by re-cloning when they want a fresh start. No more "works on my 10-row fixture, breaks on prod." - Ephemeral CI environment. The pipeline clones the relevant schema into a run-scoped namespace, applies the branch's migrations, runs the test suite against real data, asserts, and drops the clone — all inside one job. The clone exists only for the life of the build.
- Blue/green pre-deploy validation. Before promoting a schema change to prod, clone prod, apply the DDL/migration to the clone, run smoke tests and row-count diffs, and only promote if the clone passed. The clone is the rehearsal stage.
- Masked analyst sandbox. Clone prod into a sandbox, apply masking policies to PII columns, and hand analysts a real-scale, real-distribution playground that is safe because the sensitive columns are redacted.
Why real data shape matters (and fixtures don't).
- Distribution bugs. Skewed joins, null-heavy columns, and long-tail categories only appear at production scale and distribution. A 10-row fixture hides them; a clone exposes them.
- Cardinality-driven plans. Query plans depend on real statistics. Testing against a clone means the optimizer sees production-like cardinalities, so a plan that regresses in prod also regresses in the clone.
- Volume-driven failures. Memory spills, timeout thresholds, and partition-count limits are volume-sensitive. A clone reproduces them; a toy dataset does not.
The discipline that keeps it cheap and safe.
-
Drop on completion. Ephemeral clones must be dropped at the end of the job (a
finallyblock or a scheduled reaper), or storage and object sprawl accumulate. - Mask before non-prod eyes. Any clone handed to humans who should not see raw PII gets masking policies applied before access is granted.
-
Name by owner/run.
dev_alice,ci_pr_1487,bluegreen_20260905— names that make ownership and lifecycle obvious so a reaper can find and drop stale clones.
Common interview probes on clone-based workflows.
- "Why clone instead of a shared staging DB?" — isolation: each dev/run gets a private branch that cannot collide with others.
- "How do you keep CI clones from piling up?" — drop in a
finallyblock; run a scheduled reaper on naming convention. - "How do you test a destructive migration safely?" — clone, migrate the clone, diff, promote only on pass.
- "How do you give analysts prod-scale data safely?" — clone + masking policy on PII columns.
Worked example — a per-pull-request clone in a CI pipeline
Detailed explanation. The highest-leverage pattern: every pull request gets its own clone of the target schema, the branch's migrations run against it, the test suite executes against real data, and the clone is dropped when the job ends — pass or fail. Real-data testing with zero standing cost.
-
Trigger. CI job on a pull request,
PR_ID = 1487. -
Clone.
ci_pr_1487schema, cloned fromprod(or maskedstaging). - Teardown. Always drop, even on failure.
Question. Write the CI job steps that clone the schema, run migrations + tests, and guarantee teardown.
Input.
| Stage | Command | Guarantee |
|---|---|---|
| clone | CLONE prod schema | instant, isolated |
| migrate | run branch migrations | against real data |
| test | run suite | real cardinalities |
| teardown | DROP schema | always (finally) |
Code.
# ci-pipeline.yml — per-PR ephemeral clone (Snowflake example)
stages:
- clone
- test
- teardown
variables:
CLONE_DB: "ci_pr_${CI_PR_ID}"
clone_prod:
stage: clone
script:
- snowsql -q "CREATE DATABASE ${CLONE_DB} CLONE staging_masked;" # masked source
run_tests:
stage: test
script:
- snowsql -q "USE DATABASE ${CLONE_DB};"
- ./run_migrations.sh ${CLONE_DB} # apply the branch's DDL/migrations
- ./run_test_suite.sh ${CLONE_DB} # tests hit real data shape
drop_clone:
stage: teardown
when: always # runs even if tests failed
script:
- snowsql -q "DROP DATABASE IF EXISTS ${CLONE_DB};"
# Equivalent guarantee in a Python-driven job
clone_db = f"ci_pr_{os.environ['CI_PR_ID']}"
run(f"CREATE DATABASE {clone_db} CLONE staging_masked")
try:
run_migrations(clone_db)
run_tests(clone_db) # asserts against production-scale data
finally:
run(f"DROP DATABASE IF EXISTS {clone_db}") # never leak a clone
Step-by-step explanation.
- The clone stage runs
CREATE DATABASE ci_pr_1487 CLONE staging_masked— a masked derivative of prod, so tests see real shape without real PII. It completes in seconds and adds ~0 storage. - The test stage applies the branch's migrations to the clone and runs the suite. Because the data is production-scale, the tests catch distribution and cardinality bugs that a fixture would miss — a migration that locks a huge table or a query whose plan regresses shows up here, not in prod.
- The teardown stage is marked
when: always, so it drops the clone whether the tests passed or failed. In the Python version, thefinallyblock gives the same guarantee. This is the crucial discipline: an ephemeral clone that is not dropped becomes storage and object sprawl. - Each PR gets its own namespace (
ci_pr_<id>), so concurrent builds never collide — PR 1487 and PR 1490 run against fully isolated clones simultaneously. This is the isolation a shared staging database cannot provide. - Standing cost is zero: between builds, no clone exists. The only storage ever consumed is the transient divergence during a build, which vanishes on teardown. Real-data CI at the cost of the delta you write during the run.
Output.
| Property | Shared staging DB | Per-PR clone |
|---|---|---|
| Isolation between builds | none (collisions) | full (own namespace) |
| Data realism | maybe stale | fresh prod shape |
| Standing storage cost | full DB | ~0 between builds |
| Teardown | manual | automatic (finally) |
Rule of thumb. Give every pull request its own clone, run migrations and tests against it, and drop it in a finally/always step. Isolation plus real data shape plus zero standing cost is the combination fixtures can never match.
Worked example — a masked analyst sandbox from production
Detailed explanation. Analysts want to explore real, full-scale data; compliance wants PII protected. Clone prod into a sandbox, apply masking policies to the sensitive columns, grant analysts access to the sandbox only. They get real distributions; the raw PII never leaves the governed source.
-
Source.
prodwithcustomers.email,customers.ssn. -
Sandbox.
sandboxdatabase, cloned, with masking on PII. -
Access. Analyst role can query
sandbox, neverprod.
Question. Build a masked analyst sandbox from prod.customers and confirm analysts see redacted PII at full data scale.
Input.
| Column | Prod value | Analyst sees in sandbox |
|---|---|---|
| real | ****@domain.com | |
| ssn | real | XXX-XX-#### |
| order totals | real | real (not PII) |
Code.
-- 1. Clone prod into a sandbox (instant, full scale, ~0 storage)
CREATE DATABASE sandbox CLONE prod COPY GRANTS;
-- 2. Masking policies for PII
CREATE MASKING POLICY sandbox.mask_email AS (v STRING) RETURNS STRING ->
CASE WHEN CURRENT_ROLE() = 'DATA_ADMIN' THEN v
ELSE REGEXP_REPLACE(v, '.+@', '****@') END;
CREATE MASKING POLICY sandbox.mask_ssn AS (v STRING) RETURNS STRING ->
CASE WHEN CURRENT_ROLE() = 'DATA_ADMIN' THEN v
ELSE 'XXX-XX-' || RIGHT(v, 4) END;
-- 3. Attach to the sandbox columns only
ALTER TABLE sandbox.public.customers MODIFY COLUMN email SET MASKING POLICY sandbox.mask_email;
ALTER TABLE sandbox.public.customers MODIFY COLUMN ssn SET MASKING POLICY sandbox.mask_ssn;
-- 4. Grant analysts the sandbox, not prod
GRANT USAGE ON DATABASE sandbox TO ROLE analyst;
GRANT SELECT ON ALL TABLES IN SCHEMA sandbox.public TO ROLE analyst;
-- 5. Analyst query — real scale, redacted PII
SELECT email, ssn, COUNT(*) FROM sandbox.public.customers GROUP BY 1,2 LIMIT 5;
Step-by-step explanation.
-
CREATE DATABASE sandbox CLONE prod COPY GRANTSproduces a full, production-scale copy in seconds at ~0 storage, carrying existing grants so the environment is usable immediately. - Two masking policies define how PII columns render for non-admin roles: email keeps its domain but hides the local part; SSN shows only the last four digits. Admins see raw values for legitimate operational needs.
- Attaching the policies to the sandbox's columns leaves production governance untouched — the sandbox is a separate object, so its masking is independent. The raw PII physically exists (shared blocks) but is never returned to an analyst by the policy.
- Analysts are granted access to
sandbox, never toprod. They explore full-scale, realistically-distributed data — so their queries, dashboards, and models behave like they will in production — but every PII column is redacted at read time. - This is the compliant way to unlock "real data for analysts": the convenience of production shape and volume, with column-level protection that satisfies the PII requirement. Refresh by re-cloning when analysts want current data.
Output.
| Role | ssn | data scale | |
|---|---|---|---|
| DATA_ADMIN | real | real | full |
| analyst | ****@domain.com | XXX-XX-1234 | full |
| prod exposure | none | none | analysts never touch prod |
Rule of thumb. Clone with COPY GRANTS, then mask PII on the clone before granting access. Analysts get production shape and scale; compliance gets column-level redaction; production is never in the blast radius.
Worked example — a clone-migrate-verify deployment gate
Detailed explanation. Before a risky schema migration hits prod, rehearse it on a clone: clone prod, apply the migration to the clone, run row-count and checksum diffs against the source, and promote only if the diffs are within expected bounds. The clone is a full-scale dress rehearsal.
-
Change. A migration that backfills a new
regioncolumn via a join. - Gate. Clone → migrate clone → diff clone vs prod → promote or abort.
- Signal. Row counts preserved; only the new column populated; no unexpected drift.
Question. Implement a gate that clones prod, runs the migration on the clone, and asserts the migration is safe before promoting.
Input.
| Check | Expectation |
|---|---|
| row count | clone == prod (no rows lost) |
| new column filled | region NOT NULL for all rows |
| unrelated columns | unchanged (checksum match) |
| decision | promote iff all pass |
Code.
-- 1. Clone prod for the rehearsal
CREATE DATABASE premigrate CLONE prod;
-- 2. Apply the candidate migration to the CLONE only
ALTER TABLE premigrate.public.orders ADD COLUMN region STRING;
UPDATE premigrate.public.orders o
SET region = r.region
FROM premigrate.public.regions r
WHERE o.region_id = r.region_id;
-- 3. Verify: no rows lost, new column fully populated, others unchanged
SELECT
(SELECT COUNT(*) FROM prod.public.orders) AS prod_rows,
(SELECT COUNT(*) FROM premigrate.public.orders) AS clone_rows,
(SELECT COUNT(*) FROM premigrate.public.orders WHERE region IS NULL) AS unfilled,
(SELECT HASH_AGG(order_id, customer_id, total_cents)
FROM prod.public.orders) AS prod_checksum,
(SELECT HASH_AGG(order_id, customer_id, total_cents)
FROM premigrate.public.orders) AS clone_checksum;
# 4. Promote only if the rehearsal passed
row = fetch_one(VERIFY_SQL)
safe = (row.prod_rows == row.clone_rows
and row.unfilled == 0
and row.prod_checksum == row.clone_checksum) # untouched columns identical
if safe:
apply_migration("prod") # same DDL/UPDATE, now against prod
else:
abort("migration rehearsal failed; not promoting")
run("DROP DATABASE IF EXISTS premigrate")
Step-by-step explanation.
- Cloning
prodintopremigrategives a full-scale rehearsal environment in seconds. The migration will run against real row counts and real join cardinalities, so its runtime and correctness reflect what prod will experience. - The migration adds the
regioncolumn and backfills it via a join — exactly the statements that will later run against prod. Running them on the clone first surfaces lock duration, null-handling bugs, and join blow-ups without any production risk. - The verification query computes three safety signals in one shot: row counts must match (no rows dropped by the migration), the new column must be fully populated (
unfilled = 0), and a checksum over the unrelated columns must be identical between clone and prod (the migration touched only what it should). - The promotion decision is gated on all three passing. If the rehearsal fails — say the join dropped rows or left nulls — the migration is aborted and prod is never touched. Only a clean rehearsal promotes the exact same statements to production.
- The clone is dropped afterward. The whole gate cost only the transient divergence of the backfill on the clone, and it converted a risky prod migration into a rehearsed, verified one.
Output.
| Signal | Pass condition | On fail |
|---|---|---|
| row count match | prod_rows == clone_rows | abort |
| new column filled | unfilled == 0 | abort |
| checksum match | prod == clone on untouched cols | abort |
| promote | all pass | run same migration on prod |
Rule of thumb. Rehearse every risky migration on a clone and gate promotion on row-count, completeness, and checksum diffs. A clone turns "hope the migration works in prod" into "prove it worked on a full-scale copy first."
Data engineering interview question on clone-based CI
A senior interviewer might ask: "Set up a CI stage that, for each pull request, provisions an isolated database from production data, runs a destructive migration (it rewrites a large table), verifies data integrity against the source, and tears everything down — even if the migration crashes. Explain how you keep it isolated, cheap, and leak-free."
Solution Using a per-run clone with a finally-guaranteed teardown and integrity diff
# ci-destructive-migration.yml
run_migration_test:
script:
- export CLONE="ci_${CI_PR_ID}_${CI_RUN_ID}"
# 1. Isolated, instant, ~0-storage environment from a masked prod copy
- snowsql -q "CREATE DATABASE ${CLONE} CLONE staging_masked;"
after_script:
# 4. Teardown ALWAYS runs (even on job failure/cancel)
- snowsql -q "DROP DATABASE IF EXISTS ${CLONE};"
# The core logic, with guaranteed teardown and an integrity gate
clone = f"ci_{os.environ['CI_PR_ID']}_{os.environ['CI_RUN_ID']}"
run(f"CREATE DATABASE {clone} CLONE staging_masked")
try:
before = fetch_one(f"SELECT COUNT(*) c, HASH_AGG(id) h FROM {clone}.public.big")
# 2. Destructive migration — rewrites the whole table
run(f"""CREATE OR REPLACE TABLE {clone}.public.big AS
SELECT id, customer_id, upper(status) AS status, total_cents
FROM {clone}.public.big""")
# 3. Integrity gate — same row count, key set preserved
after = fetch_one(f"SELECT COUNT(*) c, HASH_AGG(id) h FROM {clone}.public.big")
assert before.c == after.c, "row count changed — migration unsafe"
assert before.h == after.h, "primary-key set changed — migration unsafe"
print("migration verified safe on full-scale clone")
finally:
run(f"DROP DATABASE IF EXISTS {clone}") # never leak a clone
Step-by-step trace.
| Step | Action | Isolation / cost |
|---|---|---|
| provision | CLONE staging_masked → per-run DB | instant, ~0 storage, private namespace |
| snapshot | count + key-hash before | integrity baseline |
| migrate | CREATE OR REPLACE (destructive) | rewrites clone only; prod untouched |
| verify | count + key-hash after | assert preserved |
| teardown | DROP in finally + after_script | guaranteed, no leak |
After this stage runs, each pull request has exercised a genuinely destructive migration against a full-scale, masked copy of production, proven that row counts and the primary-key set survived, and left nothing behind. Two PRs running at once use two different ci_<pr>_<run> databases, so they never collide, and a crash mid-migration still hits the finally and after_script teardown.
Output:
| Property | Result |
|---|---|
| Environment per run | isolated ci_<pr>_<run> database |
| Data | full-scale, masked, real distribution |
| Migration safety | count + key-hash gate |
| Prod risk | none (clone only) |
| Leak risk | none (finally + after_script drop) |
| Standing cost | ~0 between runs |
Why this works — concept by concept:
- Per-run clone — a database clone gives each PR/run a private, instant, ~0-storage namespace, so concurrent builds are fully isolated and cannot corrupt a shared environment.
-
Masked source — cloning from
staging_masked(a masked derivative of prod) means CI runs against real shape and scale without exposing PII to logs or test output. -
Destructive migration on the clone —
CREATE OR REPLACErewrites the clone's table entirely; because it is a clone, the divergence is transient and prod is never in scope. - Integrity gate — comparing row count and a hash of the key set before/after proves the migration preserved the data it must, turning "looks fine" into a checked invariant.
-
Cost — O(seconds) to provision, O(divergence) storage during the run, and O(1) teardown that returns cost to zero. The
finallyplusafter_scriptdouble-guarantee no clone leaks even on crash or cancel. Isolation and realism at the price of the transient delta.
CI/CD
Topic — data-processing
Data-processing problems on pipelines and CI
5. Cost, storage divergence, and pitfalls
Free at t0, linear in divergence — the ways zero-copy cloning quietly grows a storage bill, and how to keep it flat
The mental model in one line: a clone costs ~0 at creation and accrues storage cost only as it diverges via copy-on-write, but three amplifiers turn that clean model into a surprising bill — retention windows (Time Travel + Fail-safe) hold old block versions long after they stop being live, clone sprawl leaves dozens of forgotten dev sandboxes each owning a slice of diverged blocks, and shallow-clone mismanagement either breaks tables or silently pins storage — so the discipline is to attribute storage to clones, cap retention, and reap idle clones on a schedule. The feature is free; neglect is what costs money.
The storage accounting, precisely.
- At creation. Clone storage = its manifest only ≈ 0. The account's physical bytes are unchanged.
- After divergence. Clone storage = sum of blocks it uniquely owns (blocks it rewrote, plus blocks the source rewrote that the clone still pins). Copy-on-write means this equals the rewritten fraction, not the table size.
- The shared-block subtlety. A block is billed once while any table references it. When the source updates a block the clone still points at, that old block cannot be dropped — the clone keeps it alive. So the source's own churn can accrue storage against the clone lineage.
Retention amplifiers — why the bill exceeds the naive delta.
-
Time Travel. Old block versions are retained for
DATA_RETENTION_TIME_IN_DAYS(Snowflake) or the Delta log/VACUUM RETAINwindow (Databricks) so you can query or clone the past. Longer retention = more old blocks held = more storage. - Fail-safe (Snowflake). After Time Travel expires, Snowflake holds data for an additional non-configurable 7-day Fail-safe period. Dropped/rewritten blocks in a clone lineage sit in Fail-safe before they are finally purged — real bytes you pay for and cannot shorten.
- The compounding. With a 30-day Time-Travel window, a block you rewrote today is held ~30 days (Time Travel) + 7 days (Fail-safe) before purge. Divergence × retention is the true cost multiplier.
The sprawl pitfalls.
-
Orphaned dev clones.
dev_alicefrom three sprints ago still exists, still owns diverged blocks, still pins old source blocks. Multiply by a team and you have a silent multi-TB line item. - Forgotten CI clones. A CI job that failed before its teardown step leaves a clone behind; over months these accumulate.
-
Shallow-clone hazards (Databricks). A long-lived shallow clone either breaks on source
VACUUM(file gone) or, if the source avoids vacuuming to protect it, pins storage the source would otherwise reclaim.
The monitoring + guardrail toolkit.
-
Snowflake.
SNOWFLAKE.ACCOUNT_USAGE.TABLE_STORAGE_METRICSbreaks outACTIVE_BYTES,TIME_TRAVEL_BYTES,FAILSAFE_BYTES, andCLONE_GROUP_IDso you can attribute storage to a clone lineage. SetDATA_RETENTION_TIME_IN_DAYSlow on dev objects. -
Databricks.
DESCRIBE DETAILfornumFiles/sizeInBytes;VACUUMwith a saneRETAIN; prefer deep clones for anything durable. - Guardrails. TTL/reaper on naming convention, tag clones with an owner + expiry, short retention on non-prod, and alerts on clone count and per-lineage bytes.
Common interview probes on clone cost.
- "Why did our storage triple after we started cloning?" — divergence × retention × sprawl.
- "Does a clone cost storage immediately?" — no; only as it diverges.
- "What's Fail-safe's role?" — 7 extra non-configurable days of retained bytes after Time Travel.
- "How do you control clone cost?" — attribute via storage metrics, cap retention, reap idle clones.
Worked example — attributing storage to a clone lineage
Detailed explanation. You cannot control what you cannot see. Snowflake's TABLE_STORAGE_METRICS view separates active, Time-Travel, and Fail-safe bytes and groups tables by clone lineage, so you can point at exactly which clones own which bytes. Build the attribution query.
- Goal. Rank tables by total stored bytes, split into active/time-travel/fail-safe, grouped by clone lineage.
-
Signal. A dev clone with large
TIME_TRAVEL_BYTESis a retention problem; many rows in oneCLONE_GROUP_IDis sprawl.
Question. Write the query that attributes storage across a clone lineage and flags the expensive clones.
Input.
| Column | Meaning |
|---|---|
| ACTIVE_BYTES | bytes in currently-live partitions |
| TIME_TRAVEL_BYTES | bytes held for Time Travel |
| FAILSAFE_BYTES | bytes held for Fail-safe |
| CLONE_GROUP_ID | id shared by tables of one clone lineage |
Code.
-- Attribute storage per table, split by category, grouped by clone lineage
SELECT
clone_group_id,
table_catalog || '.' || table_schema || '.' || table_name AS full_name,
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((active_bytes + time_travel_bytes + failsafe_bytes) / POWER(1024,3), 1) AS total_gb
FROM snowflake.account_usage.table_storage_metrics
WHERE deleted = FALSE
ORDER BY total_gb DESC
LIMIT 20;
-- Roll up by clone lineage to see sprawl at a glance
SELECT clone_group_id,
COUNT(*) AS tables_in_lineage,
ROUND(SUM(active_bytes + time_travel_bytes + failsafe_bytes)
/ POWER(1024,4), 2) AS lineage_tb
FROM snowflake.account_usage.table_storage_metrics
WHERE clone_group_id IS NOT NULL AND deleted = FALSE
GROUP BY clone_group_id
ORDER BY lineage_tb DESC;
Step-by-step explanation.
- The first query lists the 20 largest tables and splits each into active, Time-Travel, and Fail-safe bytes. A dev clone showing large
time_travel_gbrelative toactive_gbis retaining far more history than a sandbox needs — a retention-config problem. -
CLONE_GROUP_IDties together every table that descends from the same original via cloning. Two tables sharing a group id share block lineage, so their combined footprint — not their individual sizes — is what the account pays for once sharing is accounted. - The second query rolls up by
clone_group_idto expose sprawl: a lineage with 15 tables and several TB is a cluster of forgotten clones each pinning diverged and old blocks. This is the view that turns "storage went up" into "these 15 clones are why." -
FAILSAFE_BYTESis worth calling out because it is not configurable — those bytes are held 7 days no matter what, so a clone that churned a lot leaves a Fail-safe tail you can only wait out, not shorten. - Armed with attribution, the fix is targeted: drop the idle clones in the biggest lineages, lower
DATA_RETENTION_TIME_IN_DAYSon the dev objects with fat Time-Travel bytes, and leave production retention where compliance needs it.
Output.
| full_name | active_gb | time_travel_gb | failsafe_gb | total_gb |
|---|---|---|---|---|
| prod.public.events | 4100.0 | 900.0 | 210.0 | 5210.0 |
| dev_alice.public.events | 60.0 | 540.0 | 40.0 | 640.0 |
| ci_pr_1487.public.orders | 12.0 | 8.0 | 3.0 | 23.0 |
Rule of thumb. Attribute before you optimise: TABLE_STORAGE_METRICS split by active/time-travel/fail-safe and grouped by CLONE_GROUP_ID tells you exactly which clones and which retention windows own the bytes. Optimise the fattest lineages first.
Worked example — the retention-window blow-up
Detailed explanation. A team sets DATA_RETENTION_TIME_IN_DAYS = 30 on a dev database "just in case," then clones a high-churn production table into it. The clone's own churn plus the inherited long retention means every rewritten block is held for 30 days + 7 Fail-safe — and the dev clone's storage balloons far past the active data size. Walk the arithmetic.
- Table. 2 TB, ~5% of blocks rewritten per day in dev experimentation.
- Retention. 30 days Time Travel + 7 days Fail-safe.
- Effect. Retained churn = 37 days of daily rewrites held simultaneously.
Question. Estimate the dev clone's storage under 30-day retention versus 1-day retention.
Input.
| Parameter | Value |
|---|---|
| Active data | 2 TB |
| Daily rewrite | 5% = 100 GB/day |
| Retention (Time Travel + Fail-safe) | 30 + 7 = 37 days |
| Alt retention | 1 + 7 = 8 days |
Code.
ACTIVE_TB = 2.0
DAILY_REWRITE_TB = 0.10 # 5% of 2 TB rewritten per day
FAILSAFE_DAYS = 7 # non-configurable in Snowflake
def clone_storage_tb(time_travel_days: int) -> float:
retained_days = time_travel_days + FAILSAFE_DAYS
retained_churn = DAILY_REWRITE_TB * retained_days # old versions held
return ACTIVE_TB + retained_churn
print("30-day retention:", clone_storage_tb(30), "TB") # 2 + 0.1*37 = 5.7 TB
print("1-day retention: ", clone_storage_tb(1), "TB") # 2 + 0.1*8 = 2.8 TB
-- The fix: set short retention on the dev object (do NOT touch prod)
ALTER DATABASE dev SET DATA_RETENTION_TIME_IN_DAYS = 1;
-- Existing over-retained history rolls off over the next day + 7 Fail-safe days.
Step-by-step explanation.
- Each day of dev experimentation rewrites ~100 GB of blocks. Under copy-on-write, the old versions of those blocks are not deleted immediately — Time Travel retains them for the configured window so you could query or clone the past.
- With a 30-day Time-Travel window plus the fixed 7-day Fail-safe, every day's 100 GB of churn is held for 37 days. At steady state, 37 days of overlapping churn are retained at once: 37 × 100 GB = 3.7 TB of retained old versions, on top of the 2 TB of active data → 5.7 TB.
- Drop the dev retention to 1 day and the retained churn falls to (1 + 7) × 100 GB = 800 GB, for 2.8 TB total — less than half. The active data is identical; only the retained history shrank.
- The lesson: retention is a multiplier on churn, and dev objects rarely need long retention. Inheriting or setting a long window on a high-churn clone is the most common way a "free" clone becomes a multi-TB bill.
- The fix is a one-line
ALTER ... SET DATA_RETENTION_TIME_IN_DAYS = 1on the dev object. Production retention stays wherever compliance requires; only the sandbox's history window shrinks. The Fail-safe 7 days cannot be shortened, so it is a floor you plan around.
Output.
| Retention (Time Travel) | Retained churn | Clone total storage |
|---|---|---|
| 30 days | 3.7 TB | 5.7 TB |
| 7 days | 1.4 TB | 3.4 TB |
| 1 day | 0.8 TB | 2.8 TB |
Rule of thumb. Set the shortest defensible DATA_RETENTION_TIME_IN_DAYS on non-prod clones — retention multiplies churn, and a high-churn clone under a long window can cost several times its active size. Fail-safe's 7 days is a fixed floor; plan around it, don't fight it.
Worked example — the shallow-clone storage-pinning incident
Detailed explanation. A subtler Databricks pitfall than the VACUUM break: to protect a long-lived shallow clone, an operator disables or lengthens VACUUM on the source, so the source can never reclaim rewritten files — because the shallow clone still references them. Storage the source should have reclaimed is pinned indefinitely. Diagnose and fix.
-
Setup.
analyst.snapshotis a shallow clone ofprod.big, kept for weeks. -
Reaction. To stop the clone breaking, the team stopped running
VACUUM prod.big. -
Effect.
prod.bigaccumulates every historical file version; storage grows without bound.
Question. Explain why source storage grew and give the correct fix.
Input.
| Observation | Cause |
|---|---|
| prod.big storage growing fast | old files never vacuumed |
| VACUUM was disabled | to protect a long-lived shallow clone |
| shallow clone references old files | so files can't be safely removed |
Code.
-- Diagnose: how many files / bytes is the source holding?
DESCRIBE DETAIL prod.big; -- numFiles and sizeInBytes far exceed live data
-- Root cause: a long-lived shallow clone pins the source's old files
-- analyst.snapshot = SHALLOW CLONE prod.big (weeks old)
-- Correct fix — convert the long-lived clone to a self-contained DEEP clone
CREATE OR REPLACE TABLE analyst.snapshot DEEP CLONE prod.big;
-- Now analyst.snapshot owns its OWN files and no longer references prod.big.
-- With the dependency gone, resume normal source housekeeping
VACUUM prod.big RETAIN 168 HOURS; -- reclaims the pinned historical files
Step-by-step explanation.
-
DESCRIBE DETAIL prod.bigshowsnumFilesandsizeInBytesfar larger than the live data warrants — the source is holding many historical file versions it would normally have vacuumed away. - The root cause is the long-lived shallow clone
analyst.snapshot, which references some of those old files.VACUUMwould delete files not live in the source, but doing so would break the shallow clone — so the team disabledVACUUM, and now nothing is ever reclaimed. - The team traded one problem (clone breaks) for a worse one (unbounded source growth). A shallow clone is the wrong tool for a weeks-long snapshot precisely because it couples the source's storage lifecycle to the clone's lifetime.
- The fix converts the snapshot to a
DEEP CLONE, which copies the referenced files intoanalyst.snapshot's own location. The clone is now self-contained and no longer references anyprod.bigfile. - With the dependency severed, normal
VACUUM prod.big RETAIN 168 HOURSresumes and reclaims the historical files the source was forced to keep. Source storage returns to its live size plus the retention window; the snapshot survives independently.
Output.
| State | Source storage | Clone safe? |
|---|---|---|
| Long-lived shallow + VACUUM disabled | unbounded growth | yes but pins source |
| After DEEP CLONE conversion | reclaimed to live + retention | yes, self-contained |
| VACUUM resumed | normal | independent |
Rule of thumb. Never keep a long-lived shallow clone that forces you to disable VACUUM — convert it to a deep clone so the source can reclaim its files. Shallow for disposable, deep for durable; a snapshot you keep for weeks is durable.
Data engineering interview question on clone cost
A senior interviewer might ask: "Finance flags that warehouse storage tripled this quarter even though ingest volume was flat. You suspect cloning. Walk me through how you'd confirm it, quantify which clones and which retention settings are responsible, and put guardrails in place so it can't silently happen again."
Solution Using storage attribution, retention capping, and an automated clone reaper
-- 1. Confirm & quantify — where did the bytes go? (per clone lineage)
SELECT clone_group_id,
COUNT(*) AS tables,
ROUND(SUM(active_bytes) / POWER(1024,4), 2) AS active_tb,
ROUND(SUM(time_travel_bytes) / POWER(1024,4), 2) AS time_travel_tb,
ROUND(SUM(failsafe_bytes) / POWER(1024,4), 2) AS failsafe_tb
FROM snowflake.account_usage.table_storage_metrics
WHERE deleted = FALSE AND clone_group_id IS NOT NULL
GROUP BY clone_group_id
ORDER BY (active_tb + time_travel_tb + failsafe_tb) DESC;
-- 2. Cap retention on non-prod objects (biggest lever on time_travel_tb)
ALTER DATABASE dev SET DATA_RETENTION_TIME_IN_DAYS = 1;
ALTER DATABASE sandbox SET DATA_RETENTION_TIME_IN_DAYS = 1;
# 3. Automated reaper — drop clones past their TTL, by naming convention + tag
import datetime as dt
STALE_DAYS = 7
rows = query("""
SELECT table_catalog AS db, created
FROM snowflake.account_usage.databases
WHERE database_name ILIKE 'ci\\_%' ESCAPE '\\'
OR database_name ILIKE 'dev\\_%' ESCAPE '\\'
""")
cutoff = dt.datetime.utcnow() - dt.timedelta(days=STALE_DAYS)
for r in rows:
if r.created < cutoff:
run(f"DROP DATABASE IF EXISTS {r.db}") # reap stale clone
log(f"reaped stale clone {r.db} (created {r.created})")
-- 4. Prevent recurrence — tag clones with owner + expiry at creation
CREATE DATABASE dev_alice CLONE staging_masked;
ALTER DATABASE dev_alice SET TAG governance.owner = 'alice',
governance.expires_on = '2026-09-12';
Step-by-step trace.
| Step | Lever | Effect on bill |
|---|---|---|
| Attribute | TABLE_STORAGE_METRICS by clone_group_id | pinpoints guilty lineages |
| Cap retention | DATA_RETENTION_TIME_IN_DAYS = 1 on dev | shrinks time_travel_tb sharply |
| Reap | scheduled DROP by naming/TTL | removes orphaned clones |
| Tag | owner + expires_on tags | makes ownership + lifecycle auditable |
After the investigation, the tripled bill resolves into a specific story: a handful of long-lived dev clones under an inherited 30-day retention, plus a backlog of CI clones that never got reaped. Capping non-prod retention to 1 day collapses the Time-Travel bytes, the reaper deletes the orphans, and tagging every future clone with an owner and expiry makes the next occurrence visible before it compounds.
Output:
| Metric | Before | After |
|---|---|---|
| Warehouse storage | 3× baseline | ~baseline + small dev delta |
| Dev/sandbox retention | 30 days | 1 day |
| Orphaned CI clones | dozens | 0 (reaped) |
| Clone ownership | unknown | tagged owner + expiry |
| Recurrence risk | silent | monitored + alerted |
Why this works — concept by concept:
-
Storage attribution —
TABLE_STORAGE_METRICSgrouped byCLONE_GROUP_IDand split into active/time-travel/fail-safe converts "storage went up" into a named list of lineages and retention windows, so you fix causes, not symptoms. -
Retention capping —
DATA_RETENTION_TIME_IN_DAYSis the biggest lever on a high-churn clone's cost, because retention multiplies daily churn; shrinking it on non-prod objects collapses the retained-history tail. - Fail-safe floor — the 7 non-configurable Fail-safe days are a fixed minimum you plan around, not a knob; knowing this stops you chasing bytes you cannot reclaim early.
- Automated reaper — a scheduled drop keyed on naming convention and TTL removes orphaned CI and dev clones before they accumulate, turning cleanup from a hope into a guarantee.
- Cost — the clone feature itself stays O(divergence); the guardrails add O(1) monitoring and a periodic reap. Net effect: storage returns to active-plus-small-delta, and the previously silent growth is now attributable, capped, and alertable.
Optimization
Topic — optimization
Optimization problems on storage cost and retention
Database
Topic — database
Database problems on cloning and lifecycle
Cheat sheet — zero-copy cloning recipes
- The one mechanism. A clone copies the manifest of block pointers, never the blocks. Blocks (Snowflake micro-partitions, Delta Parquet files) are immutable, so two tables share them safely. Creation is O(metadata) and ~0 storage; cost grows only as copy-on-write forks changed blocks. Memorise "pointers, not bytes."
-
Snowflake CLONE syntax.
CREATE TABLE t2 CLONE t1;,CREATE SCHEMA s2 CLONE s1;,CREATE DATABASE d2 CLONE d1;— all instant, all recursive for containers. AddCOPY GRANTSto carry privileges (off by default). Cloned pipes/streams/tasks arrive suspended; streams reset offset. -
Snowflake Time-Travel clone.
CLONE src AT (TIMESTAMP => '...'::timestamp_tz),AT (OFFSET => -3600)(seconds ago), orBEFORE (STATEMENT => '<query_id>')to branch just before a bad statement. Bounded by the source'sDATA_RETENTION_TIME_IN_DAYS— set it in advance to reach further back. -
Databricks shallow vs deep.
SHALLOW CLONEcopies the Delta log and references source Parquet files — instant, ~0 storage, but breaks if the sourceVACUUMs those files.DEEP CLONEcopies the files too — independent, full storage, survives source VACUUM. Shallow for disposable, deep for durable. -
Databricks incremental deep clone.
CREATE OR REPLACE TABLE bak DEEP CLONE src LOCATION 's3://...';copies only files changed since the last run — a cheap, restartable nightly snapshot-sync. PointLOCATIONat another bucket/region for a real backup. -
Databricks time travel.
SHALLOW|DEEP CLONE src VERSION AS OF 42or... TIMESTAMP AS OF '2026-09-04'branches from a past Delta version — same idea as Snowflake's AT/BEFORE. - Copy-on-write cost model. Clone storage = blocks it uniquely owns = rewritten fraction × table size, then × retention. A read-only clone stays ~free; a fully-rewritten clone ends up the size of the source. Divergence, not age, drives the bill.
-
Per-PR CI template.
CREATE DATABASE ci_${PR}_${RUN} CLONE staging_masked;→ run migrations + tests →DROP DATABASEin afinally/after_script/when: alwaysstep. Isolation + real data shape + ~0 standing cost; drop guaranteed even on crash. -
Mask before non-prod eyes. Clone with
COPY GRANTS, then attach masking policies to PII columns on the clone before granting access. Real scale and distribution for analysts; raw PII never returned and never leaves the governed source. -
Storage attribution (Snowflake).
SELECT clone_group_id, active_bytes, time_travel_bytes, failsafe_bytes FROM snowflake.account_usage.table_storage_metrics— split active vs retained bytes and group by lineage. Fattime_travel_byteson dev = retention problem; many rows perclone_group_id= sprawl. -
Retention capping.
ALTER DATABASE dev SET DATA_RETENTION_TIME_IN_DAYS = 1;on non-prod — retention multiplies churn, so it is the biggest lever on a high-churn clone's cost. Snowflake Fail-safe adds a fixed, non-configurable 7 days you plan around, not shorten. - When NOT to clone. Cross-region DR (need bytes elsewhere → replicate/deep-copy to another region), a target you will fully reload (~100% divergence → no sharing benefit), and long-lived Databricks shallow clones (convert to deep). Clone for same-region, logically-isolated, low-to-moderate-divergence branches.
Frequently asked questions
What is zero-copy cloning in one sentence?
Zero-copy cloning creates a new table, schema, or database whose metadata points at the exact same immutable storage blocks the source already references, so the operation duplicates no data, completes in seconds regardless of table size, and adds essentially zero storage at creation. In Snowflake those blocks are micro-partitions and the syntax is CREATE <object> CLONE <source>; in Databricks the blocks are Delta Parquet files and the syntax is SHALLOW CLONE (metadata) or DEEP CLONE (metadata + data). The clone is logically independent — writes to it never reach the source — and only starts costing storage when copy-on-write forks the specific blocks it rewrites.
Does a clone cost storage immediately?
No. At creation a clone owns zero unique blocks — it shares all of them with the source — so its only footprint is a small metadata manifest, effectively zero bytes. Storage begins to accrue only as the clone (or the source) diverges: a write forks the changed block via copy-on-write into a new copy the writer owns, and that new block is what shows up on the bill. A clone you only ever read from stays near-free forever; a clone you rewrite in full eventually costs as much as a real copy. Retention features (Snowflake Time Travel and the fixed 7-day Fail-safe, Databricks VACUUM windows) amplify this by holding old block versions for a while, so the effective cost is the rewritten fraction multiplied by the retention window.
Snowflake CLONE vs Databricks SHALLOW CLONE — how do they differ?
Both are metadata clones that share the source's data at creation, but they differ in physical dependency. Snowflake's CLONE is managed end-to-end by Snowflake: the clone shares micro-partitions, and Snowflake's storage layer handles reference counting so the shared partitions are never deleted while any clone references them — you never worry about the source "vacuuming" them away. Databricks' SHALLOW CLONE copies the Delta transaction log but leaves the referenced Parquet files in the source's location, so if the source runs VACUUM and removes files the shallow clone still points at, the clone breaks with FileNotFoundException. That is why Databricks shallow clones are for short-lived, disposable use and you reach for DEEP CLONE (which copies the files into the clone's own location) for anything durable. Snowflake has no shallow/deep distinction because its managed storage always behaves like the safe, reference-counted case.
Can I clone from a point in time?
Yes, and it is one of the most useful features. Snowflake couples cloning with Time Travel: CLONE src AT (TIMESTAMP => '...') branches the object as it existed at a wall-clock time, AT (OFFSET => -3600) branches from an hour ago, and BEFORE (STATEMENT => '<query_id>') branches from the instant just before a specific statement ran — the clean way to recover from a bad DELETE or backfill. Databricks offers VERSION AS OF <n> and TIMESTAMP AS OF '<ts>' on both shallow and deep clones to branch from a past Delta version. In both systems the reach-back is bounded by retention — Snowflake's DATA_RETENTION_TIME_IN_DAYS and Databricks' log/VACUUM window — so if you might need to branch from last week, you must have configured retention to cover it beforehand.
Why did my clone suddenly start costing a lot of storage?
Almost always one of three causes, and often all three. First, divergence: copy-on-write means every block you (or the source) rewrite forks a new block the clone owns, so a high-churn clone accumulates real bytes proportional to how much it changed. Second, retention amplification: old block versions are held for the Time-Travel window plus Snowflake's fixed 7-day Fail-safe, so a clone under a long retention setting keeps many days of overlapping churn at once — a 30-day window can hold ~37 days of daily rewrites simultaneously. Third, sprawl: forgotten dev sandboxes and CI clones that never got dropped each own diverged blocks and pin old source blocks. The fix is to attribute storage with TABLE_STORAGE_METRICS grouped by CLONE_GROUP_ID, cap DATA_RETENTION_TIME_IN_DAYS on non-prod objects, and reap idle clones on a schedule.
Is a Databricks shallow clone safe to keep long-term?
No — treat a shallow clone as disposable. Because it references the source's Parquet files rather than owning them, its safety depends entirely on those files continuing to exist. The moment the source runs VACUUM and physically deletes a file the shallow clone still references, reads of the clone fail. Teams that try to protect a long-lived shallow clone by disabling VACUUM on the source trade one problem for a worse one: the source can never reclaim rewritten files, so its storage grows without bound. The correct pattern is to keep shallow clones short-lived — create, use, and drop them inside the same job, before the next VACUUM — and to use a DEEP CLONE for anything that must survive days or weeks, since a deep clone copies the files into its own location and is fully self-contained. Shallow for disposable, deep for durable is the rule that keeps both storage and correctness under control.
Practice on PipeCode
- Drill the database practice library → for the storage-model, cloning, Time-Travel, and point-in-time-recovery problems senior interviewers love.
- Rehearse on the data-processing practice library → for the Delta Lake, shallow-vs-deep clone, CI-environment, and staging-pipeline patterns.
- Sharpen the cost axis with the optimization practice library → for copy-on-write divergence, retention-window sizing, and storage-attribution scenarios.
- Stack the prerequisites against PipeCode's broader 450+ data-engineering catalogue to anchor the pointers-not-bytes mental model against real graded inputs.
Lock in zero-copy cloning muscle memory
Docs explain the syntax. PipeCode drills explain the decision — when a metadata clone is instant and free, when copy-on-write starts costing storage, when a Databricks shallow clone breaks on VACUUM, and when a long retention window quietly triples your bill. Pipecode.ai is Leetcode for Data Engineering — pattern-first practice tuned for the production trade-offs data engineers actually face.
Practice database problems →
Practice optimization problems →





Top comments (0)