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 │
└─────────────────┘ └──────────────────┘

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.

You need three things before you start:

  1. Rocky installed – see Installation
  2. An existing dbt project with models in a models/ directory
  3. 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.

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:

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.

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.
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 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. 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_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.
  • 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 compound if and elif conditions and indirect {% set %} forms. It applies even when a unique_key would map the model to merge: 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 after dbt 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 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.
  • 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 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, MetricFlow metrics and semantic models, and exposures: 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.
  • dbt model contracts (contract: {enforced: true}, column data_type declarations, and constraints): 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 a contracts_dropped counter in the JSON output and in MIGRATION-NOTES.md. You then know which models had a contract to re-author. See Column-level contracts for the Rocky equivalent.

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

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)

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. A typical conversion looks like this.

-- models/stg_orders.sql
{{ config(materialized='incremental', unique_key='order_id') }}
SELECT
order_id,
customer_id,
order_date,
total_amount,
_fivetran_synced
FROM {{ source('shopify', 'orders') }}

stg_orders.sql:

SELECT
order_id,
customer_id,
order_date,
total_amount,
_fivetran_synced
FROM shopify.orders

stg_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.

The importer converts most Jinja, not all of it. It raises a warning or a failure for each pattern it cannot handle.

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
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.

{{ 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.

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 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 (E010E013): 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 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:

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.

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.

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_id

The 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.

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 no contract from dbt, so write one for each model that needs the extra rigour:

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 = "incremental"
timestamp_column = "_fivetran_synced"
[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 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.

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.

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

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

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_amount
FROM all_orders