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 │ └─────────────────┘ └──────────────────┘Read this guide if you have a dbt project and want Rocky to run those models. It covers the import itself, what the importer translates, what it refuses, and how to check the result. rocky validate-migration (section 7) cross-checks that every dbt model came across.
Rocky has no Jinja runtime, so some dbt constructs have no automatic translation. The importer reports each one instead of guessing. “What translates cleanly today, and what doesn’t” is the full list.
Prerequisites
Section titled “Prerequisites”You need three things before you start:
- Rocky installed – see Installation
- An existing dbt project with models in a
models/directory - Your warehouse credentials (Databricks host, HTTP path, token)
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.
Setup: the input dbt project
Section titled “Setup: the input dbt project”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 statusFROM {{ source('raw', 'orders') }}WHERE status != 'cancelled'fct_revenue.sql:
{{ config(materialized='table') }}
SELECT customer_id, SUM(amount) AS total_revenue, COUNT(*) AS order_countFROM {{ ref('stg_orders') }}GROUP BY customer_idThe POC has no profiles.yml and no compiled target/manifest.json. It therefore exercises the regex-based importer, and needs no warehouse credentials.
Run the importer
Section titled “Run the importer”rocky import-dbt \ --dbt-project dbt_project \ --output-dir imported \ --no-manifest \ --overwriteOutput (table mode):
dbt Migration Report====================
Project: ecommerceMethod: 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 testOutput: 2 models translated, 0 seeds copied → imported rocky.toml → imported/rocky.toml MIGRATION-NOTES.md → imported/MIGRATION-NOTES.mdWhat gets emitted
Section titled “What gets emitted”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.tomlimported/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 statusFROM raw.ordersWHERE 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:
name = "fct_revenue"intent = "Per-customer revenue rollup."
[strategy]type = "full_refresh"
[target]catalog = "warehouse"schema = "main"table = "fct_revenue"
[[tests]]type = "unique"column = "customer_id"
[[tests]]type = "not_null"column = "customer_id"
[[tests]]type = "relationships"to_table = "warehouse.main.stg_orders"to_column = "customer_id"column = "customer_id"
[[tests]]type = "in_range"min = "0"column = "total_revenue"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.
Verify the emitted repo loads
Section titled “Verify the emitted repo loads”Compiling the new repo is the cheapest end-to-end check:
cd importedrocky compile --models models ✓ stg_orders (4 columns) ✓ fct_revenue (3 columns) Compiled: 2 models, 0 errors, 0 warningsValidate the generated rocky.toml:
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. |
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 + sidecardepends_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 byrocky 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-qualifiedcatalog.schema.table- dbt generic tests (
unique,not_null,accepted_values,relationships) →[[tests]]blocks, column by column. This includes the configured forms that carryseverity:(awarnbecomes a Rocky warning, not a hard error) andwhere:(a row filter). See Generic test mapping below. - model-level
dbt_utils.unique_combination_of_columns→ a Rockycompositeuniqueness[[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.ymladapter type → a Rocky[adapter]block (DuckDB, Databricks, Snowflake, or BigQuery), or a DuckDB stub when the type is absent or unrecognised. In thetypefield, 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. The importer detects every one, lists it under “Known limitations” in MIGRATION-NOTES.md, and writes a # TODO: dbt-jinja-not-translated comment above any Jinja left in the emitted SQL:
- dbt tests with no native Rocky equivalent. Beyond the canonical four, the importer converts several
dbt_utilsanddbt_expectationstests 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), anddbt_utils.expression_is_true(→expression). See Generic test mapping. Anything outside that set — otherdbt_utils.*anddbt_expectations.*tests, project-defined generics, other model-level tests — becomes a structuredUnsupportedTestwarning per occurrence. The emitted TOML carries no stub for it. Rewrite those as a Rockyexpressiontest 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. - Raw Jinja that calls
is_incremental(), on the no-manifest or raw-manifest path: refused. Stripping the branch can delete bounded logic. Keeping it can reference a target that does not exist during bootstrap. The refusal covers compoundifandelifconditions and indirect{% set %}forms. It applies even when aunique_keywould map the model tomerge: a full-source merge is idempotent by key, but it is not the same query as dbt’s bounded one. You have two ways out. Compile dbt in an incremental context and import that manifest. Do this once you confirm the compiled SQL keeps its intended predicate and suits Rocky’s initial target state. Or rewrite the model with a strategy Rocky supports. {% for %}and{% set %}on the no-manifest path: refused. The importer lists the model as a failure rather than half-rendering it into broken SQL, because the loop or assignment body would survive exactly once. Re-run afterdbt compile, which the manifest path resolves, or rewrite the model. A{% if %}is different: the importer emits it verbatim with a TODO marker, and its body then applies unconditionally, so review it.{{ var() }}is not in this list. It converts to an@var()run-variable marker, as described above.- Unmapped
materializedvalues (dynamic_table,seed): flattened tofull_refreshand listed inMIGRATION-NOTES.md.materialized_viewis not in this group; it maps to Rocky’s ownmaterialized_viewstrategy. - Adapters Rocky does not support natively (Postgres, Redshift, and others): the generated repo stubs DuckDB so the project still loads. Replace the
[adapter]block once Rocky has an adapter for that warehouse, or pass--target-adapter <kind>to skip detection. - Custom Jinja macros that emit SQL (
{{ generate_schema_name() }}, a dynamicUNION ALLmacro): reported as failed models, with the macro name in the reason. - Python dbt models (
.pyfiles): not SQL. Rewrite them yourself. - Snapshots, MetricFlow metrics and semantic models, and exposures: not translated, but detected and counted. Each one raises a
DroppedConstructwarning and incrementsconstructs_droppedin the JSON output, so an import is never silently lossy. - dbt model contracts (
contract: {enforced: true}, columndata_typedeclarations, andconstraints): not carried over to Rocky’s contract model. The importer detects and reports them instead of dropping them. Each one emits a warning and increments acontracts_droppedcounter in the JSON output and inMIGRATION-NOTES.md. You then know which models had a contract to re-author. See Column-level contracts for the Rocky equivalent.
1. Import the dbt Project
Section titled “1. Import the dbt Project”Point rocky import-dbt at your dbt project directory:
rocky import-dbt --dbt-project ./my-dbt-project --output-dir ./rocky-modelsIt 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.tomlWhat the importer converts
Section titled “What the importer converts”These are the main patterns, in short form:
| dbt Pattern | Rocky Conversion |
|---|---|
{{ ref('model_name') }} |
Bare table reference (model_name) + depends_on in TOML |
{{ source('source_name', 'table') }} |
Fully qualified table reference (source_name.table) |
{{ config(materialized='incremental', unique_key='id') }} |
[strategy] section in TOML |
{{ this }} |
Target table reference from [target] in TOML |
schema.yml column tests (unique, not_null, accepted_values, relationships) |
[[tests]] blocks in the model sidecar TOML (see Section 9 below) |
JSON output
Section titled “JSON output”Ask for JSON with the global -o json flag when a script reads the result:
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() }}" } ]}Manifest Fast Path
Section titled “Manifest Fast Path”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
2. Review the Imported Models
Section titled “2. Review the Imported Models”Read each generated pair of files after the import. A typical conversion looks like this.
Before (dbt)
Section titled “Before (dbt)”-- models/stg_orders.sql{{ config(materialized='incremental', unique_key='order_id') }}
SELECT order_id, customer_id, order_date, total_amount, _fivetran_syncedFROM {{ source('shopify', 'orders') }}After (Rocky)
Section titled “After (Rocky)”stg_orders.sql:
SELECT order_id, customer_id, order_date, total_amount, _fivetran_syncedFROM shopify.ordersstg_orders.toml:
name = "stg_orders"
[strategy]type = "merge"unique_key = ["order_id"]
[target]catalog = "warehouse"schema = "main"table = "stg_orders"
[[sources]]catalog = "warehouse"schema = "shopify"table = "orders"Two things moved. The {{ config() }} block became [strategy], and {{ source() }} became a fully qualified reference. A config(unique_key=...) with no explicit incremental_strategy maps to Rocky’s merge strategy keyed on unique_key, not to a bare incremental block. The [[sources]] block and its qualified coordinates come from the sources.yml definition for shopify.orders. Without a matching sources.yml entry, the importer emits a warning and no [[sources]] block.
3. Handle Unsupported Jinja
Section titled “3. Handle Unsupported Jinja”The importer converts most Jinja, not all of it. It raises a warning or a failure for each pattern it cannot handle.
Common warnings
Section titled “Common warnings”| Pattern | Importer Behavior | Manual Fix |
|---|---|---|
{{ var('some_var') }} |
Converted to an @var(some_var) run-variable marker in the emitted SQL (not a warning) |
Pass the value at run time with rocky run --var some_var=value, or give the marker an inline default: @var(some_var, fallback). A marker with neither a --var binding nor a default fails to compile. |
{% if target.name == 'prod' %} |
Emitted verbatim with a # TODO marker — the body applies unconditionally, so review it |
Remove environment branching or use separate rocky.toml files per environment |
{% set ... %} variable assignments |
Refused — the model is listed as a failure rather than half-rendered | Inline the value or refactor the query |
Common failures
Section titled “Common failures”| Pattern | Reason | Manual Fix |
|---|---|---|
Custom Jinja macros ({{ generate_schema_name() }}) |
Rocky cannot interpret custom macros | Rewrite the SQL without the macro |
{% for ... %} loops generating SQL |
Dynamic SQL generation not supported | Write out the SQL explicitly or use a CTE |
{% macro ... %} definitions |
Rocky uses pure SQL, not macros | Convert shared logic to CTEs or separate models |
Python dbt models (.py files) |
Not SQL | Rewrite in SQL |
For each failed model, read the error message and rewrite the SQL yourself. Many of these macros exist to do something Rocky expresses in config instead: incremental logic, schema naming, environment branching.
generate_surrogate_key
Section titled “generate_surrogate_key”{{ 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:
[[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.
4. Configure rocky.toml
Section titled “4. Configure rocky.toml”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:
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="..."Mapping dbt config to Rocky
Section titled “Mapping dbt config to Rocky”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) |
schema_template = "mart_{region}"
[strategy]type = "merge"unique_key = ["id"]
[tags]domain = "finance"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.
5. Compile the Imported Models
Section titled “5. Compile the Imported Models”Run the compiler to type-check every imported model:
rocky compile --models ./rocky-modelsThe compiler does three things:
- Resolves
depends_onreferences 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 warningsA 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 Using Rocky with dbt Packages. 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.
6. Run Tests Locally
Section titled “6. Run Tests Locally”Once the compile passes, run the tests locally on DuckDB:
rocky test --models ./rocky-modelsTesting 4 models...
All 4 models passed
Result: 4 passed, 0 failedrocky 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.
7. Validate the Migration
Section titled “7. Validate the Migration”rocky validate-migration checks that the two projects line up:
rocky validate-migration --dbt-project ~/my-dbt-projectIt compiles both projects and compares schemas and column types. It can also compare sample data.
8. Verify the Output Before Cutover
Section titled “8. Verify the Output Before Cutover”Run both projects and compare their output before you move production traffic to Rocky.
Preview Rocky’s SQL
Section titled “Preview Rocky’s SQL”rocky plan --filter tenant=acmeThis 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.
Run on a test catalog
Section titled “Run on a test catalog”Add a test pipeline to your rocky.toml. Point it at a sandbox catalog, and reuse the adapters you already defined: the prod data adapter and the fivetran discovery adapter.
[pipeline.bronze_test]type = "replication"strategy = "full_refresh"
[pipeline.bronze_test.source]adapter = "prod"
[pipeline.bronze_test.source.discovery]adapter = "fivetran"
[pipeline.bronze_test.source.schema_pattern]prefix = ""separator = "__"components = ["source"]
[pipeline.bronze_test.target]adapter = "prod"catalog_template = "test_warehouse"schema_template = "staging"Run the test pipeline:
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.
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 commondbt_utilsanddbt_expectationstests 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.
Generic test mapping
Section titled “Generic test mapping”Take these dbt tests in schema.yml. dbt 1.7 and later also accept data_tests:, and the importer reads that as a synonym for tests:.
models: - name: fct_orders columns: - name: order_id tests: - unique - not_null - name: status tests: - accepted_values: values: ['completed', 'pending', 'cancelled'] - name: customer_id tests: - relationships: to: ref('dim_customers') field: customer_idThe importer emits [[tests]] blocks directly into models/fct_orders.toml:
[[tests]]type = "unique"column = "order_id"
[[tests]]type = "not_null"column = "order_id"
[[tests]]type = "accepted_values"values = ["completed", "pending", "cancelled"]column = "status"
[[tests]]type = "relationships"to_table = "warehouse.main.dim_customers"to_column = "customer_id"column = "customer_id"The importer resolves relationships.to: ref('m') to a fully-qualified Rocky table. It looks the name up in its own name → (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.
[positive_amount]type = "expression"expression = "amount > 0"
[known_status]type = "accepted_values"values = ["pending", "shipped", "delivered"][[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.
dbt unit tests (manifest path)
Section titled “dbt unit tests (manifest path)”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.
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 = 1status = "completed"
[test.expect]ordered = false
[[test.expect.rows]]order_id = 1status = "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’sUnitTestDefdoes 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.
Column-level contracts (manual)
Section titled “Column-level contracts (manual)”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 no contract from dbt, so write one for each model that needs the extra rigour:
[[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"]Compile with contracts
Section titled “Compile with contracts”rocky compile --models ./rocky-models --contracts ./contractsThe 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.
10. Add Intent Descriptions
Section titled “10. Add Intent Descriptions”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:
export ANTHROPIC_API_KEY="sk-ant-..."rocky ai-explain --all --save --models ./rocky-modelsRocky 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 = "incremental"timestamp_column = "_fivetran_synced"
[target]catalog = "warehouse"schema = "staging"table = "stg_orders"11. Move a Large Project in Stages
Section titled “11. Move a Large Project in Stages”A large project does not have to move in one step. This order keeps the blast radius small:
- Import and compile. Run
rocky import-dbt, fix the compile errors, add contracts on the models that matter most, and runrocky cinext to dbt in CI. - Reach test parity. Run
rocky testlocally and compare the Rocky output against the dbt output on a test catalog. Makerocky compilea required check on PRs. - 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.
- 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)Running dbt and Rocky side by side
Section titled “Running dbt and Rocky side by side”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 examplesteps: - 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-modelsRemove 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 dbt package such as fivetran/facebook_ads or fivetran/stripe produces tables in your warehouse, and Rocky can reference those tables directly as external sources. Rocky’s resolver classifies a schema-qualified table reference such as dbt_fivetran.stg_facebook_ads__ad_history as external on its own: it appears in lineage and creates no DAG dependency.
So you can leave a vendor-maintained staging package in dbt and write your own analytics in Rocky. See Using Rocky with dbt Packages for the full walkthrough.
Troubleshooting
Section titled “Troubleshooting”“model not found” after import
Section titled ““model not found” after import”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.
Incremental models do not pick up the right watermark
Section titled “Incremental models do not pick up the right watermark”A Rocky transformation incremental expects the model SQL to carry its own row filter. timestamp_column adds no filter. That is why the raw and no-manifest importer refuses unresolved Jinja that calls is_incremental(), rather than deleting bounded logic silently. If you import from a manifest, read the compiled SQL and confirm it contains the bound you intended, on _fivetran_synced or updated_at for example.
Environment-specific logic
Section titled “Environment-specific logic”dbt branches on environment with {{ target.name }}. Rocky has no environment-specific SQL. Use one rocky.toml per environment instead:
rocky compile --config pipeline.prod.toml --models ./rocky-modelsrocky compile --config pipeline.dev.toml --models ./rocky-modelsMacros that generate SQL dynamically
Section titled “Macros that generate SQL dynamically”If your dbt project relies on a macro that generates SQL, such as a union_all macro that combines tables, write the SQL out instead. A CTE with UNION ALL usually reads better anyway:
WITH all_orders AS ( SELECT * FROM raw_catalog.us_west_shopify.orders UNION ALL SELECT * FROM raw_catalog.eu_central_shopify.orders)SELECT order_id, customer_id, total_amountFROM all_orders