Shadow Mode
Shadow mode writes a pipeline’s output to shadow tables instead of production tables, or alongside them. You get to see what a change does to the data before it reaches the tables people query. Use it for new logic, schema migrations, and adapter upgrades.
What a shadow run does
Section titled “What a shadow run does” rocky run --shadow │ │ 1. Rocky rewrites every target name │ with the default _rocky_shadow suffix ▼ the pipeline runs as usual, writing to the shadow targets │ ▼ analytics.marts.fct_revenue_rocky_shadow the shadow table analytics.marts.fct_revenue production, untouched │ │ 2. rocky run compares both tables: │ row counts and schema ▼ PASS / WARN / FAIL, with the diffs │ ▼ Rocky drops the shadow table unless you pass --keep-shadowWho owns a shadow object, and how long it lives
Section titled “Who owns a shadow object, and how long it lives”The two modes answer this differently, and everything below follows from it.
rocky run --shadow the objects belong to THIS RUN built ─▶ compared ─▶ dropped A name that is already taken is refused, not overwritten.
rocky run --branch <name> the objects belong to the BRANCH built ─▶ compared ─▶ kept The next run on that branch replaces them. That is the point.An unnamed shadow run is disposable. It creates its objects and drops them when it finishes. The run output reports the comparison before cleanup. Nothing accumulates, and a second run of the same command starts clean.
Pass --keep-shadow when you need a separate rocky compare. This keeps the
objects after the run. The next one-off shadow run still refuses those occupied
names. Remove the kept objects before you run it again.
Rocky drops its shadow objects after a completed comparison, whether the verdict
is pass, warn, fail, or no_baseline. A failed verdict makes the run exit
non-zero. The run output keeps the comparison result. Use --keep-shadow if
you need to inspect the objects after a failed comparison.
A model write error or a comparison query error can leave the target’s state
uncertain. Rocky may keep those objects. The next shadow run refuses an occupied
name and prints the DROP that clears it.
Because it drops what it made, it must be sure it made them. So before writing,
it checks every derived shadow target in the warehouse. If one already exists,
the run is refused and names the object, the model, and the DROP statement
that would clear it. Rocky will not delete an object it did not create.
A named branch is a persistent workspace. Its objects survive the run so you can query them, and the next run on that branch replaces them. Rocky does not check them for prior occupancy. A branch namespace is yours wholesale, and Rocky tracks no per-object ownership there. Give a branch a namespace nothing else writes to.
Cleanup is best effort. A drop that fails is reported as a run warning rather than failing the run: the comparison already happened, and the answer you came for should not be withheld because a leftover table could not be removed.
Models a shadow run refuses
Section titled “Models a shadow run refuses”Shadow and branch runs reject content_addressed, time_interval and the
incremental family (incremental, merge, delete_insert, microbatch).
An ephemeral model is skipped, not refused: it builds nothing, and the reads
inside its inlined SQL are routed to shadow targets like any other read.
content_addressedandtime_intervalmodels persist object-storage or partition-state identities. Rewriting the warehouse target alone cannot isolate those yet.- An incremental model appends to whatever its target already holds. A
shadow target starts empty while production holds full history, so the first
run compares a partial table against a complete one, and every later run
appends again and drifts further. The comparison would get worse, not better.
Run the model as
full_refreshto shadow it, or compare it in production throughrocky compare.
Rocky also refuses a derived shadow target that matches a configured production target, or that matches another selected model’s shadow target.
How Rocky redirects a read to a shadow table
Section titled “How Rocky redirects a read to a shadow table”A model that reads another selected model’s table is routed to that upstream’s
shadow target. This holds whether or not the model declares the dependency in
depends_on. Rocky matches on the upstream’s configured
catalog.schema.table, so it redirects a physical read too.
Sometimes a reference could resolve to more than one selected upstream. Two models may have targets that share a table name in different catalogs, read as a bare or partially qualified name. Rocky refuses the run rather than guess which one to read.
Identifier case
Section titled “Identifier case”Matching follows the warehouse’s own rule for identifier case, per component.
| Warehouse | Case is part of object identity | What Rocky matches |
|---|---|---|
| DuckDB, Databricks, Trino | No | Orders and orders name one table, and either spelling is redirected |
| BigQuery | Yes | The reference must match exactly |
| Snowflake | Yes, and an unquoted reference is read as upper case | The reference must name the same object the target does |
On BigQuery and Snowflake, a model reading raw.Orders is not redirected to
the shadow of a model whose target is raw.orders. It never read that table.
Where a reference matches a routed upstream only if case is ignored, Rocky refuses the run rather than guess. Redirecting it could read a table the model never named. Leaving it alone would read production while the model writes its shadow. Spell the reference exactly as the upstream’s configured target — on Snowflake, see the note below, where spelling it the same is not enough.
Deciding whether two targets collide is the opposite question. Rocky answers
it conservatively on every warehouse. Two selected models whose targets differ
only by identifier case are always treated as one object, and Rocky refuses the
run. Case sensitivity is connection state Rocky cannot observe: a Snowflake
account may set QUOTED_IDENTIFIERS_IGNORE_CASE, and a BigQuery dataset may be
created is_case_insensitive. Assuming the two targets are distinct could let
both models write the same shadow table with no error. Rename one target so the
two differ by more than case.
CTE names on Snowflake
Section titled “CTE names on Snowflake”The same rule decides whether a CTE hides a bare table name. Rocky folds an
unquoted CTE alias and an unquoted reference to upper case before comparing them,
and leaves a quoted one as written. That is how Snowflake reads them under its
default QUOTED_IDENTIFIERS_IGNORE_CASE = FALSE.
With QUOTED_IDENTIFIERS_IGNORE_CASE = TRUE, a double-quoted identifier folds to
upper case too. Some pairs are then hidden under one setting and free under the
other. Rocky cannot observe the setting for the statement it is deciding, so it
refuses those pairs instead of guessing:
| CTE alias | Reference | FALSE (default) |
TRUE |
Rocky |
|---|---|---|---|---|
orders |
orders |
hidden | hidden | hidden |
Orders |
orders |
hidden | hidden | hidden |
"orders" |
orders |
free | hidden | refuses |
orders |
"orders" |
free | hidden | refuses |
"Orders" |
"orders" |
free | hidden | refuses |
The refusal applies only when the reference would otherwise name an upstream
that the command redirects. That covers a deferred model on --defer, a routed
upstream on a shadow or branch run, an upstream on branch promote, and a
recorded upstream on rocky replay --execute. To fix it, spell the CTE alias
and the reference alike: both unquoted, or both quoted with the same case. Or
rename the CTE.
Shadow target rewriting
Section titled “Shadow target rewriting”Suffix mode (default)
Section titled “Suffix mode (default)”Rocky appends a suffix to the table name. The default suffix is _rocky_shadow.
production: analytics.marts.fct_revenueshadow: analytics.marts.fct_revenue_rocky_shadowSchema override mode
Section titled “Schema override mode”production: analytics.marts.fct_revenueshadow: analytics.rocky_shadow.fct_revenueSchema override keeps the table name clean and groups all shadow tables together.
The two modes on a replication pipeline
Section titled “The two modes on a replication pipeline”Replication supports both modes, and they isolate differently. Under
--shadow-suffix the copy writes <table><suffix> in the pipeline’s own
target schema, and reads the unsuffixed production source. Under
--shadow-schema the whole target schema moves and the table names stay as they
are. Prefer the schema override when you want the shadow objects kept away from
production tables rather than sitting beside them.
Comparison engine
Section titled “Comparison engine”rocky run --shadow compares its targets with production before cleanup.
The result appears in text output and in JSON under shadow_comparison.
rocky compare performs the same comparison on objects you kept with
--keep-shadow. It fails if no tables were selected.
When production has no target, Rocky reports no_baseline for that target.
It does not fail the run. Rocky drops that shadow unless you use --keep-shadow.
A failed threshold does fail the run, after Rocky drops the shadow objects it created.
If Rocky cannot confirm whether production exists, the comparison errors before a verdict and may leave the shadow.
Do not combine --shadow with --resume or --resume-latest.
Remove any retained shadow objects, then restart with --shadow without a resume flag.
It finds the targets differently per pipeline type: replication discovers them
from the source, and transformation reads them off its models. It then compares
each pair the same way. rocky branch compare does the same for a branch’s
shadow schema.
The comparison evaluates two dimensions: the row count and the schema.
Row count
Section titled “Row count”Rocky counts the rows on each side and reports the difference:
shadow: 148,203 rowsproduction: 148,205 rowsdiff: -2 rows (-0.001%)verdict: PASS (within 1% warn threshold)Schema diff
Section titled “Schema diff”Rocky compares column names, types, and order:
| Diff type | Description |
|---|---|
ColumnAdded |
Column in shadow but not production |
ColumnRemoved |
Column in production but not shadow |
ColumnTypeDiff |
Same column, different type |
ColumnOrderDiff |
Same columns, different order |
No value comparison yet
Section titled “No value comparison yet”The comparison reads no rows. Two tables can hold the same number of rows and
different data, and the verdict cannot see that. For a row-level diff, use
rocky preview diff --algorithm bisection.
Thresholds
Section titled “Thresholds”rocky run --shadow uses the defaults below. rocky compare takes others as
JSON, for example --thresholds '{"row_count_diff_pct_fail": 0.10}'.
| Threshold | Default | Description |
|---|---|---|
row_count_diff_pct_warn |
0.01 (1%) | Warn if row count differs by more than this |
row_count_diff_pct_fail |
0.05 (5%) | Fail if row count differs by more than this |
allow_column_order_diff |
true | Whether column reordering is acceptable |
Verdicts
Section titled “Verdicts”| Verdict | Meaning |
|---|---|
| Pass | All comparisons within thresholds |
| Warn | Minor differences detected (e.g., row count within warn threshold, column order change) |
| Fail | Significant differences (e.g., row count beyond fail threshold, missing columns, type changes) |
| Error | Rocky could not read a table or its schema on one side. The count it could not read is null, and reasons in the JSON says why. Rocky counts an error as a failure. |
Use cases
Section titled “Use cases”- Schema migrations: check that a column rename does not change the output
- Logic changes: compare the old and the new calculation, row for row
- Adapter testing: run a new warehouse adapter beside the production one
- dbt migration: compare Rocky’s output against dbt’s output with
rocky validate-migration