This guide walks through converting an existing dbt project into a native
Phlo Transform workspace and running it — from the first analysis through to
incremental rebuilds. It uses fixtures/dbt-shop, a small but realistic dbt
project: two sources, staging views, a table mart, a keyed (merge)
incremental, a watermark incremental, folder-level config and schema tests.
The translator is a one-way migration tool, not a dbt runtime. Jinja is
scanned and classified, never executed, and the output is plain Phlo SQL —
simpler than the dbt input. See dbt-migration.md for the
full translation table.
# build the CLI (installs `phlo-transform`)
cargo install --path crates/phlo-transform-cli
# for local execution you also want the DuckDB shell
brew install duckdb # macOSEverything in this guide runs locally — no Trino, Nessie or credentials required. To target Trino instead, see "Running against Trino" below.
Point --root (or -r) at the dbt project and run translate --check. This
writes nothing — it is a dry run that classifies every resource it finds.
cd my-dbt-project
phlo-transform translate --from dbt --checkdbt migration analysis
Project: shop (.)
Resources
models 5
sources 2
singular tests 1
CLEAN
models 5
sources 2
singular tests 1
Model conversion coverage: 100.0%
Every resource gets a classification — nothing is silently treated as equivalent:
- CLEAN — translated to native semantics with no residual Jinja. Safe to run as-is.
- REVIEW — emitted, but needs a human: residual Jinja (macros,
{% set %},{% for %}, non-watermark{% if %}),ephemeralmodels (emitted as views), environment-dependent expressions (env_var,run_started_at,target.*), unresolvedref/source/var, and unconvertible config (grants, hooks). - UNSUPPORTED — not emitted at all: snapshots, analyses, disabled models, exposures, metrics, semantic models.
Use --json for the machine-readable report (per-resource issues with stable
DBT0xx codes, per-file transformations and source hashes):
phlo-transform translate --from dbt --check --jsonphlo-transform translate --from dbt --out ../generated --verify--out <dir>is required to write;--checknever writes.- Existing files are never overwritten unless
--overwriteis passed. --verifycompiles the generated workspace and exits non-zero if it does not passcheck— which is how REVIEW models with residual Jinja surface. Files are still written so you can inspect and fix them.- A manifest lands at
.phlo/migration/dbt-translation.jsonrecording what was translated, so the migration is auditable and idempotent.
Wrote 13 file(s) to ../generated
Manifest: .phlo/migration/dbt-translation.json
Workspace: ../generated
Roots: 1
Models: 5
Sources: 2
Tests: 11
check passed
The generated project is deliberately smaller than the dbt input:
generated/
├── transforms/
│ ├── staging/
│ │ ├── stg_customers.sql
│ │ ├── stg_orders.sql
│ │ └── transform.toml # from models: shop: staging: +materialized
│ └── marts/
│ ├── customer_orders.sql
│ ├── daily_revenue.sql
│ ├── orders_incremental.sql
│ └── transform.toml # from models: shop: marts: +materialized/+tags
└── tests/
├── assert_positive_amounts.sql # singular test, refs resolved
└── generated/ # schema.yml tests → SQL
├── raw__orders__id__unique.sql
├── staging__stg_orders__status__accepted_values.sql
└── ...
phlo.toml is only emitted when the project actually needs defaults (for
example a default_namespace for models that lived at models/ root). Less
config, not a copy of dbt's.
dbt input (models/marts/orders_incremental.sql):
{{ config(materialized = 'incremental', unique_key = 'order_id', incremental_strategy = 'merge') }}
select order_id, customer_id, amount, status, ordered_at
from {{ ref('stg_orders') }}
{% if is_incremental() %}
where ordered_at > (select max(ordered_at) from {{ this }})
{% endif %}Phlo output (transforms/marts/orders_incremental.sql):
-- translated from dbt model `orders_incremental` (models/marts/orders_incremental.sql)
-- @incremental key=order_id
-- @tags mart
select
order_id,
customer_id,
amount,
status,
ordered_at
from staging.stg_ordersThe key carries the merge semantics; the redundant filter is dropped (a keyed merge over a full scan is still correct).
Watermark incremental — is_incremental() containing
col > (select max(col) from {{ this }}) becomes a native window:
-- @incremental window=ordered_atSources and vars — {{ source('raw', 'orders') }} becomes the physical
relation raw.orders; {{ var('default_region') }} becomes its literal value
'eu'.
Schema tests — unique/not_null on a key column fold into @key /
@incremental key=; other not_nulls become -- @not-null col; the rest
(unique, accepted_values, relationships) become ordinary test files
under tests/generated/.
A translated project does not have to be 100% CLEAN to be useful — but REVIEW
models are emitted deliberately broken so nothing silently changes
meaning. Running check on fixtures/dbt-jaffle output:
error[PARSE001]: could not parse model SQL: sql parser error: Expected: identifier, found: {
--> transforms/marts/labelled.sql
labelled.sql still contains {{ label_status('status') }} — the macro call
is preserved verbatim. Rewrite it as plain SQL (or inline the expression) and
the model compiles. Common fixes:
| REVIEW reason | What to do |
|---|---|
project/package macro call with a dynamic body ({% %} statements, run_query) |
inline the equivalent SQL by hand — simple expression-body macros and helpers like dbt_utils.star/generate_surrogate_key/safe_cast, dbt_date.get_base_dates, dbt.date_trunc/current_timestamp already lower statically |
ephemeral model |
emitted as a view — usually fine; inline it into consumers if you want it gone |
is_incremental() else-branch kept |
model is a correct full-refresh; add @incremental if you want incremental behaviour |
env_var/target.*/run_started_at |
replace with a literal or a Phlo-native mechanism |
unresolved ref/source/var |
fix the name or supply the var |
Seed CSVs are copied to seeds/ and load automatically at run time —
no manual duckdb step is needed for them. UNSUPPORTED resources
(snapshots, exposures, metrics, semantic models, dbt unit_tests/groups)
are listed in the report and the manifest with reasons — they need a manual
decision, not a translation.
Sources in the generated SQL are physical relations (raw.orders), so create
them as ordinary tables in a DuckDB file — this mirrors what a warehouse would
already contain:
cd generated
duckdb shop.duckdb < seed.sql # create schema raw; create table raw.orders as ...Then the standard lifecycle applies:
phlo-transform doctor --adapter duckdb --duckdb-path shop.duckdb
phlo-transform check
phlo-transform plan --adapter duckdb --duckdb-path shop.duckdb
phlo-transform run --adapter duckdb --duckdb-path shop.duckdbrun is plan + apply + tests in one step. First run builds everything; tests
include both the translated singular tests and the generated schema tests.
Insert a row and update one in the source, then run again:
duckdb shop.duckdb < mutate.sql # insert new order; update order 10
phlo-transform run --adapter duckdb --duckdb-path shop.duckdbmarts.orders_incremental(@incremental key=order_id) merges: the updated row is corrected in place, the new row is inserted — no duplicates.marts.daily_revenue(@incremental window=ordered_at) appends only rows past the committed watermark.- A third run with no upstream change is a full
SKIP— state-aware planning sees nothing changed.
phlo-transform explain marts.orders_incremental # deps, columns, planned action + why
phlo-transform inspect marts.orders_incremental # incl. desired vs materialised state
phlo-transform lineage marts.customer_orders # upstream/downstream models
phlo-transform impact staging.stg_orders # what breaks if this changes
phlo-transform list # models, sources, tests
phlo-transform manifest # last generated graph artifactAll commands accept --json for agents and CI, and --select,
--upstream/--downstream, --tag for selection.
Same workspace, different adapter — no project changes:
export PHLO_TRINO_ENDPOINT=http://localhost:8080
export PHLO_TRINO_USER=phlo
export PHLO_TRINO_CATALOG=iceberg
export PHLO_TRINO_SCHEMA=analytics
phlo-transform runThe DuckDB adapter is the local/dev path; Trino + Iceberg + Nessie is the
production path (branches, Write-Audit-Publish, promotion, data diffs — see
docs/wap.md and docs/diff.md).
| Symptom | Cause / fix |
|---|---|
translate --verify exits 1 after writing |
REVIEW models with residual Jinja — expected; check tells you which files |
error: refusing to overwrite N existing file(s) |
pass --overwrite, or use a fresh --out |
error: '.' does not look like a dbt project |
run with -r pointing at the directory containing dbt_project.yml |
plan shows UNKNOWN / "blocked by compilation errors" |
fix the files check reports; nothing executes while blocked |
plan keeps showing SKIP after source data changed |
on DuckDB, source state is a schema + row-count fingerprint; in-place updates that keep the row count are invisible — apply/run the model directly or touch the schema |
inspect shows status: changed right after a run |
offline compilation can infer different types than the catalogue-enriched run; pass --adapter/--duckdb-path for accurate state |
| incremental model rebuilt fully | strategy/key changed or first bootstrap — plan prints full rebuild required with the reason |
The GIFs in docs/assets/ are recorded with VHS
from the tapes in docs/vhs/:
vhs docs/vhs/translate.tape
vhs docs/vhs/run.tape
vhs docs/vhs/incremental.tapeThe tapes operate on /tmp/phlo-demo (a copy of fixtures/dbt-shop); rebuild
the CLI first so the GIF reflects current output.


