The test failed with Unknown function CENTS_TO_DOLLARS.
We migrated one helper — the one-line cents_to_dollars nobody had touched in fourteen months — from a Jinja macro to a dbt 1.11 UDF. Then we ran dbt test against an empty target schema. Database Error in unit_test test_cents_to_dollars. Unknown function CENTS_TO_DOLLARS. The test was correct. The function wasn’t there yet. That is the post.
(amount / 100.0) → analytics.dbt_helpers.cents_to_dollars(amount)The compiled SQL says it all
- dbt version
- dbt-core
1.11.0· functions as a first-class resource type - Shipped
- 10 February 2026 — three months ago. Tristan Handy opened the original feature request in 2016
- Adapter
- Snowflake; the resource type is generic, the DDL the adapter emits is dialect-specific
- Macro replaced
cents_to_dollars(column_name)— a one-line arithmetic helper, the smallest case worth migrating- Compared
target/compiled/output before and after the migration, on the same model- CI test
- One-character change inside the function body;
dbt ls --select state:modified --state prod-artifacts/ - Unit test
- The model that calls the UDF, against an empty target schema — the failure that produced this post
- Scenario
- Crosshire trial dbt project. Numbers and DDL verbatim from the adapter’s compiled output; the pattern from a real engagement, written so the method shows in public.
A macro is what dbt compiles. A UDF is what dbt deploys.
We migrated one helper. cents_to_dollars — a one-line arithmetic function nobody had touched in fourteen months. The smallest case worth migrating, and the smallest case where the difference between a macro and a UDF would have somewhere to show. The macro version compiled to (amount / 100.0) inline, the way it always had. The UDF version compiled to analytics.dbt_helpers.cents_to_dollars(amount) — a string the warehouse has to resolve at run time. Then we ran dbt test against an empty target schema and the warehouse said Unknown function CENTS_TO_DOLLARS. The test was correct. The function wasn’t there yet. That is the post.
Tristan Handy opened the original feature request in 2016. For most of the decade since, the workaround was a Claire-Carroll-vintage on-run-start hook that pre-deployed warehouse functions before the models ran. GA arrived on 10 February 2026 — three months ago. Every tutorial since has walked the YAML. None of them shows the line in target/compiled/ where a model stops containing its helper and starts referring to one.
Start with the macro. A one-line helper that divides cents by a hundred. The model file calls it, dbt compiles the macro inline, and the warehouse receives a SELECT that is, for the warehouse, indistinguishable from one a human typed.
{% macro cents_to_dollars(column_name) %} ({{ column_name }} / 100.0) {% endmacro %}
And the model that calls it:
SELECT order_id, {{ cents_to_dollars('amount') }} AS amount_dollars FROM {{ ref('raw_orders') }}
After dbt compile, the artefact in target/compiled/ is the model with the macro expanded in place. There is no function anywhere — the arithmetic sits in the SELECT:
SELECT order_id, (amount / 100.0) AS amount_dollars FROM analytics.raw.raw_orders
The dbt 1.11 UDF version puts the same logic in functions/cents_to_dollars.sql and declares its signature in YAML:
CREATE OR REPLACE FUNCTION cents_to_dollars(amount_cents BIGINT) RETURNS FLOAT LANGUAGE SQL AS $$ amount_cents / 100.0 $$;
functions: - name: cents_to_dollars arguments: - name: amount_cents data_type: bigint returns: data_type: float
And the model body changes shape. The Jinja entry point changes too — ref() is for models, seeds, and snapshots; UDFs use {{ function('name') }}, which resolves to the fully-qualified function identifier dbt deployed:
SELECT order_id, {{ function('cents_to_dollars') }}(amount) AS amount_dollars FROM {{ ref('raw_orders') }}
After dbt build, the compiled artefact contains the resolved function path — an object the warehouse looks up at run time:
SELECT order_id, analytics.dbt_helpers.cents_to_dollars(amount) AS amount_dollars -- calls warehouse function FROM analytics.raw.raw_orders
That line is the whole point of the post. Not cosmetic. The macro version is self-contained — the model carries its definition with it; nothing on the warehouse needs to exist except the source table. The UDF version contains a reference — the model relies on analytics.dbt_helpers.cents_to_dollars being present before the query is even valid. Every helper in this file produces the same answer the macro did. And the next time someone drops the function out-of-band, the model errors. The compiled SQL says it all: a macro is what you wrote; a UDF is what you reference.
A macro is text. A UDF is a row in INFORMATION_SCHEMA.FUNCTIONS. The first is what dbt compiles; the second is what dbt deploys — and the test step is the first thing that finds out.
Drop the function and the next query errors.
Back to the cents_to_dollars helper we just migrated. The first thing the new compiled output told us was that dbt run — the command our CI had been calling for years — wasn’t the right command anymore. The macro version never cared. dbt run compiled the macro inline and the warehouse received a single CREATE OR REPLACE TABLE. The macro existed only as text, expanded the moment dbt sent the model SQL.
The UDF version splits that into two DDL statements in a defined order. Functions are nodes in the DAG; the model that calls {{ function('cents_to_dollars') }} depends on the function the same way a model depends on a seed. dbt build issues CREATE OR REPLACE FUNCTION first — against the schema the project targets for function resources — and only then runs the model. dbt run alone does not deploy the function. It never has. We learned that the way most teams will: by reading a stack trace.
One Jinja detail to flag while it’s in front of us, because the tutorials skip it. ref() resolves to a model, seed, or snapshot. UDFs use a different macro — {{ function('name') }}, singular, lowercase — which resolves to the fully-qualified function identifier in the target warehouse. Same dependency-edge mechanics in the DAG, different Jinja entry point. We mention it once and never again.
Then we wrote the operational difference out as a table. Not to spec the feature — to make the deploy-order shift readable at a glance, because it is the thing every existing CI job touches.
| Step | Macro | UDF |
|---|---|---|
dbt compile |
Expands the macro inline; produces a single SELECT in target/compiled/ |
Leaves the function call in the model; produces a CREATE OR REPLACE FUNCTION in target/compiled/functions/ |
dbt build |
One DDL statement: the model (dbt run behaves the same) |
Two DDL statements, in order: function first, then model. dbt run alone does not process function nodes — use dbt build |
| Warehouse footprint | The table or view the model produces | The function and the table/view — the function persists between runs |
Node type in manifest.json |
macro.<project>.cents_to_dollars |
function.<project>.cents_to_dollars |
| Resource selector | None — macros are not DAG nodes and aren’t selectable | --select "resource_type:function,name:cents_to_dollars" |
The deploy-order shift is the only thing that changes for a small project. For a large one, it changes the entire Slim CI plan the moment somebody edits a function body — which is what we tried next.
The macro hid the change. The UDF surfaced it.
Back to the cents_to_dollars helper, the same one. We made the smallest possible edit — replaced 100.0 with 100 inside the function body — and ran the Slim CI selector dbt projects everywhere lean on without naming. The macro version of this experiment is well known: edit a macro, nothing else, and state:modified returns an empty set. Macros are recorded in manifest.json by name; a body change inside the macro does not propagate to the modified set of downstream models. Slim CI rebuilds nothing. Production silently inherits the new arithmetic on the next scheduled run. Most teams have lived with that.
The UDF version closes the gap. The same one-character edit, this time against the function file:
$ dbt ls --select state:modified --state prod-artifacts/ function.my_project.cents_to_dollars # the UDF itself model.my_project.orders # downstream model that calls {{ function('cents_to_dollars') }}
Two rows. The function flagged — expected. The downstream orders model flagged too, not because its file changed, but because a node it depends on did. state:modified on UDFs propagates through the DAG the same way it does on models. A change to cents_to_dollars is, as far as Slim CI is concerned, a change to every model that invokes {{ function('cents_to_dollars') }}. Run dbt build --defer --state prod-artifacts/ and the function resolves to the state-manifest copy until the new one is deployed. The blast radius is visible, and the build plan knows the order.
The same edit, in two different resource types, produced two different CI plans. The macro version hid the change. The UDF version surfaced it. — Field note, §3
This is the property to lean on. For any helper a non-trivial number of models depend on, the change blast radius becomes visible to your CI before it becomes visible to production. The cost is the deploy-order shift from §2. The trade is worth it for any helper whose definition you would otherwise debate in a Slack thread after the fact.
- Function body changes are picked up by
state:modified. Bodies are stored in the manifest; dbt diffs them between the prod artefacts and the current ones. - Downstream models flag as modified through the DAG. A
{{ function('name') }}call on a UDF is a dependency edge; modify the function, the model flags. - Macro body changes are still invisible to
state:modified. If you have helpers you want CI to notice, migrate them. If a helper is truly stable boilerplate, leaving it as a macro is fine. - Renaming a function is a breaking change. The old function node disappears; the new one appears; every consumer flags as modified. The same as renaming a model.
The failure didn’t mention the unit test.
Now we’re back at the moment from the dek. cents_to_dollars migrated, orders.sql rewritten to call {{ function('cents_to_dollars') }}, unit test in place. We ran dbt test against an empty target schema — the way a developer does on a fresh branch before they bother with dbt run. The test errored. The error did not say what we expected.
Unit tests in dbt run against a real adapter connection. They mock ref() — you declare rows in YAML, dbt builds a temporary CTE under the model, the model SQL runs against the CTE instead of the source table. That works for macros: the macro is text, the test executes the expanded text, and the warehouse never needs anything beyond the empty schema dbt is pointed at.
It does not work the same way for UDFs. The model now calls cents_to_dollars(amount) — a function call the warehouse resolves at execution time. If the function isn’t in the schema dbt is pointed at, the query fails. Not as an assertion failure. As a warehouse SQL error, written in the warehouse’s vocabulary:
unit_tests: - name: test_cents_to_dollars model: orders given: - input: ref('raw_orders') rows: - {order_id: 1, amount: 1000} expect: rows: - {order_id: 1, amount_dollars: 10.0}
Run that test against a schema where the function has not been deployed — a freshly-created CI schema, a developer who ran dbt test before dbt run, a target where someone dropped the function out-of-band — and the failure reads like this:
Failure in test test_cents_to_dollars (models/schema.yml)
Database Error in unit_test test_cents_to_dollars
002003 (42883): SQL compilation error:
Unknown function CENTS_TO_DOLLARS
“Unknown function” is not how a unit test usually announces itself. The reflex on a developer machine is to assume the test rows are wrong; on CI, that the assertion changed. Neither is true. The function simply isn’t there, and a unit test cannot mock its way around the absence the way it mocks a ref().
Unit tests on UDF-bearing models require the UDF to exist in the target warehouse at test time. The macro version of the same model would have passed without a warehouse object in sight.
Once we knew the test was telling the truth, the fix took one line of CI config. Run dbt build --select "resource_type:function" before dbt test — or, simpler, replace dbt test in CI with dbt build on the whole selector and let the resource-type ordering deploy functions first. dbt run won’t do it on its own. That single command swap is the operational consequence of the migration; everything in §5 assumes it.
One macro to one UDF. The order matters.
Everything above came from one migration on one helper. Here is the order we ran it in, so the next helper is faster than the one that produced the post. Multiply by however many helpers are worth the deploy-order shift; the cost per helper drops sharply after the first.
Catalogue. Open macros/ and list every macro that returns a SQL expression callable inside a SELECT. Helpers that return DDL fragments, materialisation overrides, and audit-pattern macros stay as macros — UDFs are first-class but they are not a replacement for the meta-programming Jinja does. The candidates are the small, expression-shaped helpers: arithmetic conversions, string cleanups, type coercions.
Author. One .sql file per UDF under functions/, containing the CREATE OR REPLACE FUNCTION body. One entry in functions/schema.yml per UDF, declaring the argument names, types, and return type. The example from §1 is the whole pattern.
Rewrite. Replace the Jinja macro call in every model with a direct SQL call to the function. The Jinja goes away; the model gets shorter; the diff in target/compiled/ is the only one that matters.
Deploy. Run dbt build on the affected selector. The function is created first; the model and its unit tests run after; the order is implicit in the DAG. On the first run, the function will not exist in production yet, so this is also the moment to confirm your CI job runs dbt build rather than dbt test in isolation — otherwise the next pull request to touch the function will surprise the test step the way §4 describes.
Verify. Two checks. First, target/compiled/<model>.sql should show the function call where the macro expansion used to be — the §1 diff, in your own project. Second, change one character inside the function body and run dbt ls --select state:modified --state prod-artifacts/ — the function and every model that calls it via {{ function('cents_to_dollars') }} should both appear. If they do, the Slim CI behaviour from §3 is wired up correctly.
- The helper builds SQL, not data. Anything that returns a CASE statement or an audit clause or a
UNION ALLblock is a Jinja construct — not a UDF. UDFs run inside a row; macros run inside a query. - You target multiple warehouses with different UDF dialects. A macro abstracts over Snowflake / Databricks / BigQuery syntax cleanly. A UDF binds you to one adapter’s function syntax.
- The helper is genuinely stable. If a helper has not changed in two years, the Slim CI advantage doesn’t materialise. The deploy-order dependency is pure overhead.
- The helper takes a column reference, not a value. Macros can splice column names into SQL; UDFs receive evaluated values. A macro that does
SUM(CASE WHEN {{ col }} > 0 THEN 1 END)cannot become a UDF without a rewrite.
A UDF is not a better macro. It is a different resource type with a different deploy story, a different CI story, and a different test story. It earns its place when its body needs to be visible to CI — not because someone wrote a tutorial about the YAML. — Field note, §5
macros/ directory, marks the candidates a UDF migration would actually surface in Slim CI, and writes the first migration as a worked example. You keep the catalogue, the migration, and the criteria.
- dbt docs · UDFs — the 1.11 resource type,
functions/directory convention, theschema.ymlshape (arguments,returns,data_type) - dbt docs · Function arguments — the
argumentsblock reference, argument naming, type declarations - dbt docs · UDFs vs macros (FAQ) — when a helper belongs as a UDF and when a macro is still the right call
- dbt docs · Node selection syntax — the
resource_type:functionselector and howstate:modifiedresolves modified nodes frommanifest.json - dbt docs · Continuous integration — Slim CI, the
--stateflag, the prod-artifacts pattern - dbt docs · Unit tests — what unit tests mock (
ref(),source()) and what they execute against the adapter - dbt docs ·
dbt build— the resource-type ordering that runs functions before tests on the same selector
This note describes one migration on one helper, run on a Crosshire trial dbt 1.11 + Snowflake project so the method can be shown in public. The compiled-SQL diff, the state:modified output, and the Unknown function CENTS_TO_DOLLARS error are verbatim from the run. The dbt review described above ships in every Crosshire warehouse-platform engagement. — Crosshire
The test failed with Unknown function CENTS_TO_DOLLARS.
We migrated one one-line helper from a Jinja macro to a dbt 1.11 UDF and ran dbt test. The warehouse said the function didn’t exist. The test was correct. The function wasn’t there yet — and the gap between those two facts is the whole point of the post.
(amount / 100.0) → analytics.dbt_helpers.cents_to_dollars(amount)The compiled SQL says it all
1We compiled. The macro body had always expanded inline — (amount / 100.0) right in the SELECT. The UDF version compiled to analytics.dbt_helpers.cents_to_dollars(amount). A reference, not a definition. An object the warehouse has to resolve before the query is even valid.
2We changed one character. Same edit, two resource types. Inside the macro: state:modified returned nothing. Inside the UDF: state:modified returned the function and the downstream model that calls it. Slim CI saw the change. The macro version had been hiding it.
3We ran the test. dbt test against an empty target schema, the way a developer does on a fresh branch. Unknown function CENTS_TO_DOLLARS. The test was correct. The function wasn’t there. dbt build deploys functions before tests; dbt test alone never will.
A macro is what dbt compiles. A UDF is what dbt deploys.
We started where most migrations start — one helper, one model. cents_to_dollars, a one-line arithmetic function nobody had touched in fourteen months. In the model file the two versions read the same. In target/compiled/ they read very differently: the macro version is a self-contained SELECT the warehouse can run on its own; the UDF version calls out to a function that has to be present in the target schema before the query is even valid.
-- Macro version: arithmetic inline SELECT order_id, (amount / 100.0) AS amount_dollars FROM … -- UDF version: call to a warehouse function SELECT order_id, cents_to_dollars(amount) AS amount_dollars FROM …
Two lines. Every other difference in this post follows from them.
The macro hid the change. The UDF surfaced it.
Same helper, same one-character edit. Inside the macro: nothing. dbt ls --select state:modified --state prod-artifacts/ returned an empty set, even though every downstream model now had different arithmetic. Slim CI rebuilt nothing. Production silently inherited the new behaviour. Most teams have lived with that for years.
Inside the UDF the same edit propagated. The function flagged as modified; the orders model that invokes {{ function('cents_to_dollars') }} flagged too — through the DAG, not because its file changed. The same edit, recorded by CI, with a rebuildable blast radius. That is the argument for migrating any helper a non-trivial number of models depend on.
dbt test alone is no longer enough.
Back to the test failure from the dek. Unknown function CENTS_TO_DOLLARS is not how a unit test usually announces itself. The reflex on a developer machine is to assume the test rows are wrong; on CI, that the assertion changed. Neither is true. Unit tests mock ref(); they do not, and cannot, mock a UDF. The function call inside the model SQL is resolved by the warehouse, and if the warehouse doesn’t have the function, the test errors with a warehouse SQL error that doesn’t mention the test.
The fix is one command. Replace dbt test in CI with dbt build on the same selector — dbt build orders functions before tests, so the function is in the warehouse before the test executes. dbt run won’t do it on its own. That single swap is the operational consequence of the migration. Skip it and the next pull request to touch a function will fail the way ours did.
- The full compiled-SQL diff. The macro source, the YAML signature, and the
target/compiled/output side by side — with the one line that changes between the two artefacts called out. - The CI plan that the same edit produced twice. Macro vs UDF on
dbt compile,dbt run, manifest node type, and theresource_type:functionselector — with the operational consequence of each row. - The order we ran the migration in. Catalogue, author, rewrite, deploy, verify — with the four cases where the macro is still the right call (multi-adapter, schema-shaped helpers, stable boilerplate, column-name splicing).