Skip to content

Migrating from dbt

rocky import-dbt converts a dbt Core project into a Rocky project. Point it at a dbt project directory and it writes a Rocky repo to disk. You get a body file and a .toml sidecar per model, a rocky.toml, and a MIGRATION-NOTES.md listing everything it could not translate.

my-dbt-project/ rocky import-dbt imported/
┌─────────────────┐ ┌────────────────┐ ┌──────────────────┐
│ dbt_project.yml │ │ manifest fast │ │ rocky.toml │
│ models/*.sql │───────►│ path when one │─────►│ models/*.sql │
│ models/*.yml │ │ exists, else │ │ models/*.toml │
│ profiles.yml │ │ the regex │ │ seeds/ │
│ target/ │ │ extractor │ │ MIGRATION- │
│ manifest.json │ └────────────────┘ │ NOTES.md │
└─────────────────┘ └──────────────────┘

You get:

  • a Rocky repo from one command, with models, sidecars, tests and contracts converted;
  • a MIGRATION-NOTES.md that lists every construct the importer could not translate;
  • a checked result: the repo compiles, validates, and matches the dbt project before cutover.

Rocky has no Jinja runtime, so some dbt constructs have no automatic translation. The importer reports or refuses each one instead of guessing. “What translates cleanly today, and what doesn’t” is the full list.

Step Command Section
Import rocky import-dbt 1
Review and fix Jinja read the sidecars 2, 3
Configure the warehouse rocky.toml 4
Compile and test rocky compile, rocky test 5, 6
Validate and compare rocky validate-migration, rocky plan 7, 8
Tests and contracts [[tests]], .contract.toml 9
Cut over in stages leaf models first 11

You need three things before you start:

  1. Rocky installed – see Installation
  2. An existing dbt project with models in a models/ directory
  3. Credentials for your warehouse, for example a Databricks host, HTTP path and token. See Connect to a warehouse.

You do not need dbt installed. The importer reads .sql files directly and parses Jinja expressions with its own regex-based extractor.

Walkthrough: end-to-end against a tiny dbt project

Section titled “Walkthrough: end-to-end against a tiny dbt project”

This section runs the whole path against a small runnable example. It mirrors the POC at examples/playground/pocs/06-developer-experience/03-import-dbt-validate/. Every command and every snippet below came from that POC, run against the current rocky build.

The POC ships a small dbt project: two models, one source, and a schema.yml of generic tests.

dbt_project/
├── dbt_project.yml # name: ecommerce, profile: ecommerce, +materialized: table
└── models/
├── sources.yml # source 'raw' / table 'orders'
├── schema.yml # generic tests: unique, not_null, accepted_values, relationships, dbt_utils.accepted_range
├── stg_orders.sql # {{ config(materialized='view') }} + {{ source('raw', 'orders') }}
└── fct_revenue.sql # {{ config(materialized='table') }} + {{ ref('stg_orders') }}

stg_orders.sql:

{{ config(materialized='view') }}
SELECT
order_id,
customer_id,
amount,
LOWER(status) AS status
FROM {{ source('raw', 'orders') }}
WHERE status != 'cancelled'

fct_revenue.sql:

{{ config(materialized='table') }}
SELECT
customer_id,
SUM(amount) AS total_revenue,
COUNT(*) AS order_count
FROM {{ ref('stg_orders') }}
GROUP BY customer_id

The POC has no profiles.yml and no compiled target/manifest.json. It therefore exercises the regex-based importer, and needs no warehouse credentials.

Terminal window
rocky import-dbt \
--dbt-project dbt_project \
--output-dir imported \
--no-manifest \
--overwrite

Output (table mode):

dbt Migration Report
====================
Project: ecommerce
Method: regex
Models: 2 total
2 imported successfully (view: 1, full_refresh: 1)
Sources: 1 tables from 1 sources
1 mapped to Rocky
Tests: 8 total
8 converted to contracts
(1 of them composite / multi-column)
Next Steps:
1. rocky compile
2. rocky test
Output: 2 models translated, 0 seeds copied → imported
rocky.toml → imported/rocky.toml
MIGRATION-NOTES.md → imported/MIGRATION-NOTES.md

The importer writes a self-contained Rocky repo. Here is the full layout:

imported/
├── MIGRATION-NOTES.md
├── rocky.toml
└── models/
├── _defaults.toml
├── stg_orders.sql
├── stg_orders.toml
├── fct_revenue.sql
└── fct_revenue.toml

imported/rocky.toml (the importer wrote a DuckDB stub because no profiles.yml was found):

# rocky.toml — generated by `rocky import-dbt`
# Connection fields use ${VAR} env-var substitution. Set the env vars
# listed in MIGRATION-NOTES.md before running `rocky run`.
# Default per-model target: catalog=warehouse, schema=main (see models/_defaults.toml).
[adapter]
type = "duckdb"
path = "warehouse.duckdb"
[pipeline.default]
type = "transformation"
models = "models/**"
[pipeline.default.target]
adapter = "default"

imported/models/_defaults.toml (directory-level target defaults):

[target]
catalog = "warehouse"
schema = "main"

imported/models/stg_orders.sql (Jinja resolved to bare references):

SELECT
order_id,
customer_id,
amount,
LOWER(status) AS status
FROM raw.orders
WHERE status != 'cancelled'

imported/models/stg_orders.toml. The view materialization maps to Rocky’s own view strategy. The model description becomes intent. The schema.yml generic tests become [[tests]] blocks:

name = "stg_orders"
intent = "Staged orders, one row per order, with a normalised status."
[strategy]
type = "view"
[target]
catalog = "warehouse"
schema = "main"
table = "stg_orders"
[[sources]]
catalog = "warehouse"
schema = "raw"
table = "orders"
[[tests]]
type = "unique"
column = "order_id"
[[tests]]
type = "not_null"
column = "order_id"
[[tests]]
type = "not_null"
column = "customer_id"
[[tests]]
type = "accepted_values"
values = ["pending", "completed", "shipped"]
column = "status"

imported/models/fct_revenue.toml follows the same shape. It carries full_refresh, plus relationships and in_range tests (see Generic test mapping).

imported/MIGRATION-NOTES.md records everything that did not translate: counts of skipped tests and macros, the environment variables each adapter needs, and a “Known limitations” list. Read it first.

Compiling the new repo is the cheapest end-to-end check:

Terminal window
cd imported
rocky compile --models models
✓ stg_orders (4 columns)
✓ fct_revenue (3 columns)
Compiled: 2 models, 0 errors, 0 warnings

Validate the generated rocky.toml:

Terminal window
rocky -c rocky.toml validate
ok Config syntax valid (v2 format)
ok adapter.default: duckdb (local)
ok pipeline.default: transformation / models='models/**'
ok 2 transformation models loaded
ok DAG valid (2 nodes, no cycles)
Validation complete.

A clean rocky compile plus a clean rocky validate is the success criterion. The POC’s run.sh stops here, then runs rocky validate-migration as a separate cross-check that every dbt model has a matching Rocky model.

Running the emitted repo against real data

Section titled “Running the emitted repo against real data”

rocky -c rocky.toml plan and then rocky apply <plan-id> work once the source data exists in the warehouse. That is the one precondition the importer cannot supply. The dbt project references {{ source('raw', 'orders') }}, and the importer translates that to FROM raw.orders. It neither creates nor populates the source. Load the source rows into the configured warehouse before you run rocky apply: into warehouse.duckdb for the DuckDB stub, or into your real Databricks or Snowflake target.

What translates cleanly today, and what doesn’t

Section titled “What translates cleanly today, and what doesn’t”

The importer translates each {{ config(...) }} key onto a Rocky sidecar field:

dbt {{ config(...) }} Rocky sidecar
materialized='table' | 'incremental' | 'view' The [strategy] block. view maps to Rocky’s own view strategy.
materialized='ephemeral' type = "ephemeral". Rocky inlines the model into each consumer as a CTE, as dbt does. A consumer imported from the manifest’s compiled SQL already carries dbt’s __dbt__cte__<model> CTE and runs as-is.
unique_key=... The merge strategy, with unique_key as an array.
alias='name' [target].table, the output relation, so the data lands in the aliased table rather than one named after the node. Dropping this would mis-route data silently.
materialized='microbatch' A merge strategy by default, or time_interval. Choose with --microbatch-as <merge|time_interval>. merge reuses the dbt unique_key for an idempotent key-upsert, so dbt microbatch’s partition-replace becomes a key upsert. time_interval maps the batch onto Rocky’s partition-window model. Either way, MIGRATION-NOTES.md records the choice for review.
merge_update_columns=[...] The merge strategy’s update_columns.

It translates the rest of the project like this:

  • {{ ref('model') }} → bare table reference + sidecar depends_on
  • {{ source('s', 't') }} → fully qualified reference + sidecar [[sources]]
  • {{ var('name') }} / {{ var('name', default) }} → an @var(name) / @var(name, default) run-variable marker left in the emitted SQL, resolved at run time by rocky run --var name=value (see Handle unsupported Jinja)
  • dbt tags (node- and folder-level) → the sidecar [tags] block (<tag> = "true")
  • {{ this }} → the model’s own fully-qualified catalog.schema.table
  • dbt model governance → Rocky model governance. See Access, groups and versions below.
  • dbt generic tests (unique, not_null, accepted_values, relationships) → [[tests]] blocks, column by column. This includes the configured forms that carry severity: (a warn becomes a Rocky warning, not a hard error) and where: (a row filter). See Generic test mapping below.
  • model-level dbt_utils.unique_combination_of_columns → a Rocky composite uniqueness [[tests]] block over the same column tuple. The columns come from the test config, so Rocky needs no model schema.
  • Top-level dbt_project.yml → the project name and the seeds path
  • <dbt_project>/seeds/ → copied verbatim into <out>/seeds/
  • profiles.yml adapter type → a Rocky [adapter] block (DuckDB, Databricks, Snowflake, or BigQuery), or a DuckDB stub when the type is absent or unrecognised. In the type field, the parser resolves YAML anchors and aliases (&anchor / *alias) and {{ env_var('VAR', 'default') }}. A profile that templates its adapter type therefore detects the right warehouse instead of falling back to the DuckDB stub.

The importer does not translate the items below, by design. Rocky has no Jinja runtime, so each one needs a manual pass. Some models are refused. Other unsupported expressions are reported in MIGRATION-NOTES.md and marked in the emitted SQL:

  • dbt tests with no native Rocky equivalent. Beyond the canonical four, the importer converts several dbt_utils and dbt_expectations tests to native Rocky assertions: unique_combination_of_columns, accepted_range / expect_column_values_to_be_between (→ in_range), expect_column_values_to_match_regex (→ regex_match), expect_column_values_to_be_in_set (→ accepted_values), and dbt_utils.expression_is_true (→ expression). See Generic test mapping. Anything outside that set — other dbt_utils.* and dbt_expectations.* tests, project-defined generics, other model-level tests — becomes a structured UnsupportedTest warning per occurrence. The emitted TOML carries no stub for it. Rewrite those as a Rocky expression test or a quality-pipeline check.
  • Singular tests in tests/ (custom SQL): copy and rewrite them yourself.
  • dbt macros and dbt_packages/. Rocky has no Jinja runtime, so no macro body expands.
  • Incremental models on the raw path: converted when the model uses dbt’s standard {% if is_incremental() %} WHERE <col> > (SELECT MAX(<col>) FROM {{ this }}) {% endif %} filter. The block becomes Rocky’s @incremental_filter, which loads every row on the first run. Another is_incremental() use is kept as a commented TODO with no watermark, so rocky compile refuses the model (E037) until you finish it. An incremental model with no is_incremental() block is refused, including models configured in dbt_project.yml or a model properties YAML file. Run dbt compile --full-refresh and import the artifact pair. See rocky import-dbt. A non-incremental raw model that calls is_incremental() is also refused. Versioned models are refused because raw import cannot resolve their per-version settings.
  • Jinja control flow ({% if %}, {% for %}, {% macro %}, {% call %}, and whitespace-control forms) on the no-manifest path: refused. Raw conversion cannot evaluate these statements and would keep a conditional body without its guard. Run dbt compile --full-refresh and import with the manifest, or rewrite the model. {{ var() }} still converts to an @var() run-variable marker when used outside a config expression.
  • Unmapped materialized values (dynamic_table, seed): flattened to full_refresh and listed in MIGRATION-NOTES.md. materialized_view is not in this group; it maps to Rocky’s own materialized_view strategy.
  • Profile types the importer does not map (Postgres, Redshift, and others): the generated repo stubs DuckDB so the project still loads. Replace the [adapter] block with the matching one — Rocky has PostgreSQL, Redshift and SQL Server adapters — or pass --target-adapter <kind> to skip detection.
  • Custom Jinja macros that emit SQL ({{ generate_schema_name() }}, a dynamic UNION ALL macro): reported as failed models, with the macro name in the reason.
  • Python dbt models (.py files): not SQL. Rewrite them yourself.
  • Snapshots convert to type = "snapshot" models. Both legacy {% snapshot %} blocks and YAML snapshots (dbt 1.9+) convert, from a manifest or from the snapshot-paths directories. The model keeps dbt’s column names (dbt_valid_from, dbt_valid_to, dbt_scd_id, dbt_updated_at, dbt_is_deleted) and writes no is_current column, so rocky run can continue a table dbt built. Two differences: new versions get Rocky’s dbt_scd_id hash, not dbt’s, and dbt_is_deleted is a BOOLEAN, not a string. A snapshot with a custom strategy macro, no unique_key, an expression as unique_key, or Jinja control flow in its body is reported as a failure with the reason. Without a manifest, a project-level snapshots: block in dbt_project.yml also fails the import, and the target schema is the configured one: dbt’s generate_schema_name may have built the table elsewhere, so check the target before the first run. With a manifest, Rocky targets the relation dbt resolved.
  • MetricFlow metrics and semantic models: not translated, but detected and counted. Each one raises a DroppedConstruct warning and increments constructs_dropped in the JSON output, so an import is never silently lossy.
  • Exposures: each one becomes a downstream consumer, a file at consumers/<name>.toml. The dbt type, owner, url and description carry over. A consumer keeps a dependency only when it names a model the import wrote, so the emitted project compiles. Sources, seeds and models that failed to import are not carried over. Each exposure that lost something raises a DroppedConstruct warning, increments constructs_dropped, and is listed in MIGRATION-NOTES.md. consumers_imported in the JSON output counts the files written.
  • dbt model contracts (contract: {enforced: true}): converted. The importer writes <model>.contract.toml next to the model. Each declared column becomes a [[columns]] entry and is listed in [rules] required. data_type maps to a Rocky type (a decimal maps to Decimal, with any precision and scale). Some names mean different types on different warehouses, so the importer reads the dbt adapter from the manifest: float is Float64 on BigQuery, PostgreSQL and Redshift and Float32 on DuckDB, Databricks and Spark, and a bare timestamp is Timestamp on those six. On Snowflake, float and the other floating-point names (double, double precision, real, float4, float8, which all land as FLOAT), a bare timestamp and the integer names stay unchecked, as do float and timestamp when the manifest names no adapter. A not_null or primary_key constraint is not checked: Rocky cannot prove NOT NULL for a column read from a source, so nullable = false would refuse a valid model (E012). The file marks each such column with a comment; add a not_null test to check the data. A column type Rocky has no name for stays required and unchecked. The constraints unique, check, foreign_key and custom have no contract check. A model with such a part raises a warning and increments contracts_dropped, so you know which files to review. See Column-level contracts.

Point rocky import-dbt at your dbt project directory:

Terminal window
rocky import-dbt --dbt-project ./my-dbt-project --output-dir ./rocky-models

It scans my-dbt-project/models/ for .sql files and writes a body file and a sidecar per model into ./rocky-models/:

rocky-models/
├── stg_orders.sql
├── stg_orders.toml
├── stg_customers.sql
├── stg_customers.toml
├── fct_orders.sql
├── fct_orders.toml
├── dim_customers.sql
└── dim_customers.toml

Every conversion is listed in What translates cleanly today. Tests land as [[tests]] blocks (see Section 9).

Ask for JSON with the global -o json flag when a script reads the result:

Terminal window
rocky -o json import-dbt --dbt-project ./my-dbt-project --output-dir ./rocky-models
{
"version": "<rocky-version>",
"command": "import-dbt",
"imported": 42,
"warnings": 3,
"failed": 2,
"imported_models": ["stg_orders", "stg_customers", "fct_orders", "..."],
"warning_details": [
{
"model": "stg_payments",
"category": "UnsupportedTest",
"message": "dbt_expectations.expect_column_pair_values_A_to_be_greater_than_B has no native equivalent",
"suggestion": "rewrite as a Rocky expression test or a quality-pipeline check"
}
],
"failed_details": [
{
"name": "complex_macro_model",
"reason": "unsupported Jinja: custom macro {{ generate_schema_name() }}"
}
]
}

If your dbt project has a compiled manifest at target/manifest.json, Rocky uses it without being asked. The import is more accurate that way, because the compiled SQL has all its Jinja already resolved.

Two flags override that choice:

  • --manifest path/to/manifest.json: use this manifest
  • --no-manifest: ignore any manifest and use the regex-based import

Read each generated pair of files after the import. The walkthrough above shows one in full. Three points to check:

  • {{ config(materialized='incremental', unique_key='id') }} with no explicit incremental_strategy becomes a merge strategy keyed on unique_key, not a bare incremental block.
  • The [[sources]] block comes from the sources.yml entry. Without a matching entry, the importer emits a warning and no [[sources]] block.
  • {{ ref() }} and {{ source() }} become bare and fully qualified references.

The manifest path uses SQL compiled by dbt. The raw path converts a limited set of Jinja expressions and refuses statement tags.

A custom macro, {% if %}, {% for %}, {% macro %} or a Python model makes the importer refuse that model. The list above names each case. For each failed model, read the error and rewrite the SQL. Many macros do something Rocky expresses in config: incremental logic, schema naming, environment branching. Move shared macro logic into CTEs or separate models. A @var(name) marker with neither a --var binding nor an inline default (@var(name, fallback)) fails to compile.

{{ dbt_utils.generate_surrogate_key([...]) }} is a common macro, and the importer does not convert it. It raises an UnsupportedMacro warning and replaces the call with a /* TODO: unsupported macro */ marker in the emitted SQL. Rewrite it by hand as a surrogate key: a computed column whose value is a deterministic hash over a set of input columns. Declare it as a [[surrogate_key]] block in the model sidecar. The block names the output column and the input columns, and rocky run injects the hash column at materialization time:

models/fct_orders.toml
[[surrogate_key]]
name = "order_key"
columns = ["order_id", "customer_id"]

Drop the {{ ... }} expression from the model SQL and let the sidecar add the column. Rocky’s hash matches what dbt-utils produces on the same warehouse. It casts each input to text, coalesces NULL to the same _dbt_utils_surrogate_key_null_ sentinel, joins the values with a - separator, and MD5-hashes the result. The expression is dialect-correct on DuckDB, Databricks, Snowflake, and BigQuery, so the hash values equal the dbt values on the matching warehouse.

Create a rocky.toml in your project root. Rocky uses named adapters and named pipelines.

A replication pipeline needs two adapter roles. A data adapter reads and writes table bytes; Databricks and Snowflake do this. A discovery adapter enumerates the source schemas to replicate; Fivetran and Airbyte do this. Databricks handles data only, so a replication source names it for data movement and points [source.discovery] at a discovery-capable adapter. If you ran dbt on Databricks fed by Fivetran, your settings map straight across:

[adapter.prod]
type = "databricks"
host = "${DATABRICKS_HOST}"
http_path = "${DATABRICKS_HTTP_PATH}"
token = "${DATABRICKS_TOKEN}"
[adapter.fivetran]
type = "fivetran"
kind = "discovery"
api_key = "${FIVETRAN_API_KEY}"
api_secret = "${FIVETRAN_API_SECRET}"
destination_id = "${FIVETRAN_DESTINATION_ID}"
[pipeline.bronze]
type = "replication"
strategy = "incremental"
timestamp_column = "_fivetran_synced"
[pipeline.bronze.source]
adapter = "prod"
catalog = "raw_catalog"
[pipeline.bronze.source.discovery]
adapter = "fivetran"
[pipeline.bronze.source.schema_pattern]
prefix = ""
separator = "__"
components = ["source"]
[pipeline.bronze.target]
adapter = "prod"
catalog_template = "warehouse"
schema_template = "staging"
[pipeline.bronze.execution]
concurrency = 8
[state]
backend = "local"

Set the environment variables:

Terminal window
export DATABRICKS_HOST="your-workspace.cloud.databricks.com"
export DATABRICKS_HTTP_PATH="/sql/1.0/warehouses/abc123"
export DATABRICKS_TOKEN="dapi..."
export FIVETRAN_API_KEY="..."
export FIVETRAN_API_SECRET="..."
export FIVETRAN_DESTINATION_ID="..."
dbt (profiles.yml / dbt_project.yml) Rocky (rocky.toml)
host [adapter.prod] host
http_path [adapter.prod] http_path
token [adapter.prod] token
catalog [pipeline.<name>.target] catalog_template
schema [pipeline.<name>.target] schema_template
threads [pipeline.<name>.execution] concurrency

Folder-level config (+materialized, +schema)

Section titled “Folder-level config (+materialized, +schema)”

dbt’s dbt_project.yml sets per-directory defaults such as marts: +materialized: table and +schema: marts. Rocky’s equivalent is a config group. Define the shared routing and strategy once in models/groups/<name>.toml, then have each member model opt in with group = "<name>" in its sidecar. The mapping is direct:

dbt folder-level Rocky config group (models/groups/<name>.toml)
+materialized: table (or incremental, etc.) [strategy] block
+schema: marts schema_template = "marts" (a literal is a template with no placeholders)
models/groups/daily_marts.toml
schema_template = "mart_{region}"
[strategy]
type = "merge"
unique_key = ["id"]
[tags]
domain = "finance"
models/fct_orders.toml
group = "daily_marts"
[args]
region = "emea"

A group differs from a dbt folder default in one way that matters when you migrate. A dbt folder default applies to every model in the directory on its own. A Rocky config group applies only to the models that name it with group = "<name>". Precedence runs per-model sidecar over group over models/_defaults.toml, so a member can still override anything the group sets.

A group also takes enforce = true. With that set, a member model that pins a field the group controls, its target schema or its strategy, fails to load. It does not diverge quietly. The group stops being an overridable default and becomes a guarantee that every model in it routes and materializes the same way.

dbt’s model governance maps onto Rocky model governance. The importer carries each part over:

dbt Rocky
access: private | protected | public access = "..." in the model sidecar. The meaning is the same.
group: finance on a model access_group = "finance" in the model sidecar.
groups: entry with owner: {name, email} models/groups/finance.toml with an [owner] table.
versions: with v: 1, v: 2 Models orders_v1 and orders_v2, plus the version declaration models/orders.toml.
latest_version: 2 latest_version = 2 in the declaration. FROM orders reads the latest version through a view.
deprecation_date on a version deprecation_date = "YYYY-MM-DD" on the version in the declaration. A reader gets W048.
{{ ref('orders', v=1) }} orders_v1, a pinned version.
{{ ref('orders') }} to a versioned model orders, the latest version.

Two points to check:

  • As in dbt, only public models may be read from another project. Rocky checks this in rocky publish-ir and in the consumer’s [[sources]].
  • Rocky versions are whole numbers. A dbt version such as 1.5 is refused with a reason; rename it to a whole number first.

When dbt’s latest version already materializes to the unversioned name (an alias), the importer sets latest_alias = false in the declaration, so the alias view does not collide with that table.

With a manifest, dbt has already resolved every access and group, including +access and +group folder defaults from dbt_project.yml. Without a manifest, the importer reads access and group only from the model YAML or its config: block. It does not read the folder defaults, so set those on the models after import.

Run the compiler to type-check every imported model:

Terminal window
rocky compile --models ./rocky-models

The compiler does three things:

  • Resolves depends_on references into a DAG
  • Type-checks column references across model boundaries
  • Reports type mismatches, contract violations, and missing dependencies
✓ stg_orders (5 columns)
✓ stg_customers (4 columns)
✓ fct_orders (7 columns)
✓ dim_customers (6 columns)
Compiled: 4 models, 0 errors, 0 warnings

A bare table reference the importer left unresolved, one whose name matches no model in the project, is not a compile error. Rocky treats it as an external reference: it appears in lineage and creates no DAG dependency. See Read dbt Package Tables. The diagnostics you will see after an import come from the type checker and from contracts:

  • Missing depends_on: the importer can miss a dependency that was implicit in dbt, such as a {{ ref() }} inside a macro. Add it to the model’s TOML, and the reference then resolves to a project model instead of an external one.
  • Type mismatches (E011): Rocky infers types from upstream models. It reports a column used in a context its type does not fit.
  • Contract violations (E010–E013): a missing required column, a wrong type, a nullability violation, or a removed protected column fails the compile against a .contract.toml. See Section 9.

Once the compile passes, run the tests locally on DuckDB:

Terminal window
rocky test --models ./rocky-models
Testing 4 models...
All 4 models passed
Result: 4 passed, 0 failed

rocky test executes each model’s SQL against DuckDB in dependency order. It catches SQL syntax errors and runtime errors, and it needs no warehouse connection.

rocky validate-migration checks that the two projects line up:

Terminal window
rocky validate-migration --dbt-project ~/my-dbt-project

It compiles both projects and compares schemas and column types. It can also compare sample data.

Run both projects and compare their output before you move production traffic to Rocky.

Terminal window
rocky plan --filter tenant=acme

This prints the SQL Rocky will generate for each model. Compare it against the dbt compile output for the same models.

rocky emit-sql --models ./rocky-models gives you the same comparison without a warehouse connection. It renders the compiled SQL for every transformation model, in the shape dbt compile writes to target/. That SQL is also plain runnable SQL you keep if you ever stop using the engine. See No lock-in for the full walkthrough.

Add a second pipeline to rocky.toml. Copy [pipeline.bronze], reuse the prod and fivetran adapters, and change only these keys:

[pipeline.bronze_test]
type = "replication"
strategy = "full_refresh"
# source, source.discovery and source.schema_pattern: same as [pipeline.bronze]
[pipeline.bronze_test.target]
adapter = "prod"
catalog_template = "test_warehouse"
schema_template = "staging"

Run the test pipeline:

Terminal window
plan_id=$(rocky plan --pipeline bronze_test --filter tenant=acme --output json | jq -r .plan_id)
rocky apply "$plan_id"

Then compare row counts, column types, and data values between the dbt tables and the Rocky tables.

Rocky accepts dbt’s node selection syntax on --select and --exclude. The table maps the common dbt commands. See Node selection for the full grammar.

dbt Rocky
dbt ls --select <sel> rocky list --select <sel>
dbt compile --select <sel> rocky compile --select <sel>, or rocky emit-sql --select <sel> for the SQL
dbt run --select <sel> rocky run --select <sel>
dbt test --select <sel> rocky test --select <sel>
dbt docs generate --select <sel> rocky docs --select <sel>
--select +model, model+, 2+model, @model The same graph operators
--select tag:nightly --select tag:nightly. Rocky matches a [tags] key or value.
--select path:models/staging The same
--select config.materialized:incremental The same. table matches Rocky’s full_refresh.
--select source:raw+ The same. Rocky matches the external tables a model reads.
--select state:modified+ --state path/to/artifacts --select state:modified+ --state-ref main. Rocky compares against a git ref, not a manifest.
--exclude <sel> The same
--defer --state path/to/artifacts --defer, optionally --defer-to <schema>

9. Convert dbt Tests to Rocky Tests and Contracts

Section titled “9. Convert dbt Tests to Rocky Tests and Contracts”

rocky import-dbt translates two kinds of dbt test onto Rocky sidecars:

  • The four canonical column-level generic tests (unique, not_null, accepted_values, relationships), plus several common dbt_utils and dbt_expectations tests that have a native Rocky equivalent. Each becomes a [[tests]] block on the model sidecar. See Generic test mapping for the full list.
  • Unit tests from manifest.unit_tests (dbt 1.8 and later). Each becomes a [[test]] block on the matching model sidecar. This is manifest-only: the regex path never sees a unit test.

Everything else needs a manual step: column-level type and nullability contracts, project-defined generics, and singular tests.

The importer reads tests: in schema.yml, and data_tests: (dbt 1.7 and later) as a synonym. It emits [[tests]] blocks into models/<model>.toml. The walkthrough above shows real output. It resolves relationships.to: ref('m') to a fully-qualified Rocky table, using its own name to (catalog, schema) map over the imported models. A cross-project ref falls back to the importer defaults. rocky test runs these tests against the materialised tables.

dbt Test Rocky [[tests]]
not_null type = "not_null" + column
unique type = "unique" + column
accepted_values type = "accepted_values" + values = [...] + column
relationships type = "relationships" + to_table + to_column + column
dbt_utils.unique_combination_of_columns (model-level) type = "composite" + kind = "unique" + columns = [...]
dbt_utils.accepted_range, dbt_expectations.expect_column_values_to_be_between type = "in_range" + min / max (at least one) + column (numeric bounds only)
dbt_expectations.expect_column_values_to_match_regex type = "regex_match" + pattern + column
dbt_expectations.expect_column_values_to_be_in_set type = "accepted_values" + values = [...] + column
dbt_utils.expression_is_true type = "expression" + expression

Any other dbt_utils or dbt_expectations test, plus project-defined generics and other model-level tests, becomes an UnsupportedTest warning naming the model, the column, and the test. Rewrite those as a Rocky expression test or a quality-pipeline check. The importer writes no stub for them in the emitted TOML.

Consolidating repeated tests into named definitions

Section titled “Consolidating repeated tests into named definitions”

The importer writes one inline [[tests]] block per column, so a not_null you apply across twelve models lands as twelve identical blocks. You can define a test once and apply it by name, as a dbt generic test does. Put each test in models/test_definitions.toml and reference it from each sidecar with [[use_test]]. This is an authoring step you do after the import. The importer does not do it for you.

models/test_definitions.toml
[positive_amount]
type = "expression"
expression = "amount > 0"
[known_status]
type = "accepted_values"
values = ["pending", "shipped", "delivered"]
models/fct_orders.toml
[[use_test]]
name = "positive_amount"
column = "total_amount"
[[use_test]]
name = "known_status"
column = "status"

test_definitions.toml holds named entries written as [name], not as an array of [[...]] blocks. Each entry is a test type plus its parameters. A [[use_test]] reference binds a named test to a column at the use site, and may override the column, the severity, or the row filter. An unknown name is a hard error at load. These resolve into the same [[tests]] the importer emits inline, so rocky test --declarative runs them against the configured warehouse in the same way.

If your dbt project has compiled to a manifest.json and declares unit_tests: blocks (dbt 1.8 and later), run rocky import-dbt --manifest target/manifest.json. The importer walks manifest.unit_tests and writes each entry as a [[test]] block in the matching model’s sidecar TOML. It strips the ref('upstream_model') and source('s', 't') wrappers on given.input down to bare references.

models/fct_orders.yml
unit_tests:
- name: stamps_status_when_completed
model: fct_orders
given:
- input: ref('stg_orders')
rows:
- { order_id: 1, status: 'completed' }
expect:
format: dict
rows:
- { order_id: 1, status: 'completed' }
# Rocky: models/fct_orders.toml — emitted by `rocky import-dbt`
[[test]]
name = "stamps_status_when_completed"
[[test.given]]
ref = "stg_orders"
[[test.given.rows]]
order_id = 1
status = "completed"
[test.expect]
ordered = false
[[test.expect.rows]]
order_id = 1
status = "completed"

The importer reports three counters on the --output json payload and in MIGRATION-NOTES.md: unit_tests_found, unit_tests_converted, and unit_tests_skipped. Two warnings explain a skip:

  • OrphanUnitTest: the unit test targets a model the importer did not pick up. Skipped, and counted as skipped.
  • UnsupportedUnitTestFormat: expect.format = "csv" or "sql", a fixture reference, or any other shape Rocky’s UnitTestDef does not model yet. Skipped.

CSV and SQL fixtures, and overrides: blocks, wait until Rocky’s runtime test runner supports them. The [[test]] blocks the importer does emit execute under rocky test, as of engine-v1.52.0. The runner seeds a fresh in-memory DuckDB with each given fixture, materializes the model against it, and compares the output to expect. The comparison treats the rows as a multiset by default, and as an ordered list when expect.ordered is set.

A test checks rows at run time. For a guarantee on column types and nullability at compile time, add a .contract.toml next to the model. The importer generates a contract only from an enforced dbt contract (see above). For any other model that needs the extra rigour, write one:

contracts/stg_orders.contract.toml
[[columns]]
name = "order_id"
type = "Int64"
nullable = false
[[columns]]
name = "customer_id"
type = "Int64"
nullable = false
[[columns]]
name = "total_amount"
type = "Decimal"
nullable = false
[rules]
required = ["order_id", "customer_id", "total_amount"]
protected = ["order_id"]
Terminal window
rocky compile --models ./rocky-models --contracts ./contracts

The compiler checks every model against its contract. If a model’s output breaks the contract, through a missing column, a wrong type, or a removed protected column, the compile fails.

An intent is a plain-English description of what a model does, stored in its sidecar. Rocky’s AI commands read it. Add intent to your imported models and you can then use ai-sync, which propagates a schema change, and ai-test, which generates tests.

Generate intent for every model at once:

Terminal window
export ANTHROPIC_API_KEY="sk-ant-..."
rocky ai-explain --all --save --models ./rocky-models

Rocky reads each model’s SQL, writes a plain-English description, and saves it to the TOML config:

# stg_orders.toml (after ai-explain --save)
name = "stg_orders"
intent = "Stage raw Shopify orders with order_id, customer, date, and amount columns"
depends_on = []
[strategy]
type = "merge"
unique_key = ["order_id"]
[target]
catalog = "warehouse"
schema = "staging"
table = "stg_orders"

A large project does not have to move in one step. This order keeps the blast radius small:

  1. Import and compile. Run rocky import-dbt, fix the compile errors, add contracts on the models that matter most, and run rocky ci next to dbt in CI.
  2. Reach test parity. Run rocky test locally and compare the Rocky output against the dbt output on a test catalog. Make rocky compile a required check on PRs.
  3. Cut over, leaf first. Switch execution one layer at a time, starting with the models nothing depends on. Watch output parity for a week or two before you move the next layer.
  4. Finish. Move the remaining models, drop the dbt steps from CI, and set up the Dagster integration if you want an orchestrator.

Leaf first means the models at the downstream edge of the DAG go first, because nothing reads them:

upstream downstream
┌────────────┐ ┌──────────┐ ┌───────────┐
│ stg_orders │───►│ fct_... │───►│ reporting │
└────────────┘ └──────────┘ └───────────┘
▲ ▲ ▲
│ │ │
move third move second move first
(no dependents)

You can run both tools on the same project while you migrate. Keep the dbt models/ directory and the Rocky rocky-models/ directory separate, and run both in CI:

# GitHub Actions example
steps:
- name: dbt compile
run: dbt compile
- name: Rocky compile
run: rocky compile --models ./rocky-models --contracts ./contracts
- name: Rocky test
run: rocky test --models ./rocky-models

Remove the dbt steps once Rocky covers every model.

Keeping dbt packages without converting them

Section titled “Keeping dbt packages without converting them”

You do not have to convert everything. A vendor package such as fivetran/stripe builds tables in your warehouse. Rocky reads a schema-qualified table such as dbt_fivetran.stg_facebook_ads__ad_history as an external source: it appears in lineage and creates no DAG dependency. See Read dbt Package Tables.

The importer names each model after its SQL file’s stem, so stg_orders.sql becomes stg_orders. If your dbt project renames models with {{ config(alias='...') }}, a depends_on reference may not match. Read each TOML file’s name field and update the depends_on references to match.

An incremental model imported as full_refresh

Section titled “An incremental model imported as full_refresh”

A dbt incremental model with the standard is_incremental() watermark filter imports as a Rocky incremental model with @incremental_filter. Without that filter there is no watermark to apply. A Rocky incremental model without one is refused with E037, because it would re-insert every row on each run. So the importer maps such an append-style dbt model to full_refresh, which rebuilds from the model SQL and cannot duplicate rows:

  • an incremental model with no unique_key
  • incremental_strategy = 'append', even with a unique_key
  • incremental_strategy = 'merge' with no unique_key
  • an incremental_strategy the importer does not recognise
  • a microbatch model with no unique_key

Each one appears as a warning. To keep incremental behaviour, give the model a unique_key and set incremental_strategy to 'merge' or leave it unset. It then maps to merge. Otherwise, rewrite it as a time_interval model with @start_date and @end_date.

Any of these is refused when its dbt SQL uses is_incremental() in a form other than the standard watermark filter. The compiled SQL can keep a delta filter. As full_refresh, every run would replace the table with only recent rows. Rewrite it by hand: remove the filter, then use merge with a unique_key or a time_interval model.

The raw and no-manifest importer refuses every effectively incremental model it resolves from inline, project, or model properties config. It has no compiled SQL or per-model run result. It also refuses Jinja control flow, versioned models, and config expressions it cannot resolve.

dbt branches on environment with {{ target.name }}. Rocky has no environment-specific SQL. Use one rocky.toml per environment instead:

Terminal window
rocky --config pipeline.prod.toml compile --models ./rocky-models
rocky --config pipeline.dev.toml compile --models ./rocky-models

Write the SQL out instead. A CTE with UNION ALL usually reads better than a union_all macro. See Handle unsupported Jinja.