In dbt, how do you write a custom generic test that can be reused from schema YAML?
answer
- it is a macro with a special tag
- two bound variables do the parameterising
- extra signature arguments become YAML keys
- {% test name(model, column_name) %}
basics
~20 sDefine a Jinja block with the test tag taking model and column_name arguments, save it under tests/generic or macros, and return the failing rows. Any model's YAML can then declare it by name and pass extra arguments.
solid answer
~40 sYou wrap the failing-rows query in a `{% test my_test(model, column_name) %} ... {% endtest %}` block and put the file in `tests/generic/` (or `macros/`, which dbt also searches). Inside, `{{ model }}` renders as the relation being tested and `{{ column_name }}` as the column, so the body is ordinary SQL with two placeholders. Any extra keyword arguments you add to the signature become YAML keys at the call site. Then declare it like a built-in: under a column, `- not_negative`, or with arguments, `- valid_range: {min_value: 0}`. Column-level tests take `(model, column_name)`; a model-level test takes just `(model)` and is declared under the model rather than under a column. The result is a first-class DAG node with the same configs — severity, where, store_failures — as any built-in test.
code
jinja · 10 lines{% test valid_range(model, column_name, min_value, max_value=None) %}
select *
from {{ model }}
where {{ column_name }} < {{ min_value }}
{% if max_value is not none %}
or {{ column_name }} > {{ max_value }}
{% endif %}
{% endtest %}go deeper
You are unlikely to be asked to write one, but know that dbt lets you define your own reusable tests and that they are declared in YAML just like the built-ins.
Be able to write the block from memory: the test tag, the model and column_name arguments, and a body that selects the violating rows.
Show taste about when a custom test earns its keep versus a singular test or an existing package, and be aware that shadowing a built-in name changes behaviour project-wide.
Own the shared test library: what belongs in an internal package used across projects, how it is versioned and reviewed, and where the line sits between central standards and team-local checks.
## Why write one The four built-in generic tests cover keys, nulls, enumerations and referential integrity. Everything else — value ranges, monotonic timestamps, a percentage column that must sum to 100 per group, a string that must match a pattern — is either a singular test written once, a package test, or a generic test you author. Authoring one is worth it the moment the same rule applies to several models. ## The anatomy ```jinja {% test not_negative(model, column_name) %} select * from {{ model }} where {{ column_name }} < 0 {% endtest %} ``` Three things are happening: 1. `{% test <name> %} ... {% endtest %}` declares a generic test named `not_negative`. Under the hood dbt turns it into a macro called `test_not_negative`, which is why older projects (pre-dbt 0.20) define these as plain macros with a `test_` prefix — both still work, but the `{% test %}` block is the current form. 2. `model` is bound to the relation under test and renders as a fully qualified `database.schema.table` (or the subquery, for an ephemeral model). You never write the table name yourself. 3. `column_name` is bound to the column the test is declared under. Omit it from the signature and you have a **model-level** test, declared under the model's own `tests:` key rather than under a column. ## Where the file goes dbt looks for generic test definitions in the `tests/generic/` directory and in `macros/`. One test per file, named after the test, is the convention. No registration step is needed — dbt parses the project and the name becomes available in YAML. ## Extra arguments Any additional keyword arguments in the signature become YAML keys: ```jinja {% test valid_range(model, column_name, min_value, max_value=None) %} select * from {{ model }} where {{ column_name }} < {{ min_value }} {% if max_value is not none %} or {{ column_name }} > {{ max_value }} {% endif %} {% endtest %} ``` ```yaml - name: discount_pct tests: - valid_range: min_value: 0 max_value: 100 ``` Defaults in the signature make arguments optional, and Jinja conditionals let one test cover several shapes — but resist building a test with eight arguments and nested conditionals. At that point the Jinja is harder to review than four small tests. ## Overriding a built-in A generic test defined in your project shadows one of the same name from a package, and a test defined in the root project shadows dbt's own. Teams occasionally redefine `unique` to add a `where` clause or use a cheaper formulation on a specific warehouse. It works, but it is a heavy hammer: every `- unique` in the project silently changes meaning, so it is worth a comment and a code-review conversation. ## Naming, configuring and running The auto-generated node name follows the pattern `<test_name>_<model>_<column>_<args>`, which gets long; add `name:` to the declaration to override it. You can attach a default config inside the definition with `{{ config(severity='warn') }}` at the top of the test block, and callers can override it per declaration. Run it exactly like any other: `dbt test --select <model>`, or `dbt build`. ## Check the packages first Before hand-rolling, look at `dbt_utils` — it ships `expression_is_true`, `accepted_range`, `unique_combination_of_columns`, `not_null_proportion`, `recency`, `equal_rowcount` and more. `dbt_expectations` ports a large set of Great Expectations-style assertions. A candidate who says "first I check whether dbt_utils already has it" is showing the same instinct as one who checks the standard library before writing a utility function. ## The register in an interview This question is a differentiator rather than a gate. Many competent dbt users have never authored a generic test, and no one is failed for that. What earns credit is knowing that the extension point exists, that it is just Jinja plus SQL with two bound variables, and that packages usually got there first.
- What changes if you omit column_name from the signature?It becomes a model-level generic test: it is declared under the model's own tests key rather than under a column, and the body can inspect any columns it likes through `{{ model }}`. That is the shape used for assertions spanning several columns, such as a combination-uniqueness check.
- What happens if your project defines a generic test named unique?It shadows dbt's built-in for the whole project, so every `- unique` declaration now runs your SQL. That is a legitimate way to add a standard `where` clause or a warehouse-specific formulation, but it changes behaviour invisibly at hundreds of call sites, so it belongs in a reviewed, commented file.
- Before writing a custom test, what would you check?Whether `dbt_utils` or `dbt_expectations` already ships it. dbt_utils covers ranges, expression assertions, multi-column uniqueness, recency and row-count equality; reaching for a maintained package beats maintaining your own Jinja for the same rule.
saying these in an interview costs you the question
- Says a custom test must return true rather than failing rows
- Puts the SQL in models/ and expects dbt to treat it as a test
- Thinks the model argument must be written as a literal table name
- Believes custom tests cannot take extra arguments
- Rewrites a check dbt_utils already provides