What you will be able to do
- Explain how a constraint declared in YAML ends up being checked by the data platform rather than by dbt
- State the two prerequisites a model must meet before its constraints are applied
- Write column-level and model-level constraints, including foreign keys built with ref()
- Predict which constraint types a given platform defines and enforces, and use warn_unenforced and warn_unsupported correctly
- Decide when a constraint can replace a data test and when the test must stay
Key concept
Enforced constraint — A rule on a column or set of columns that dbt writes into the table's DDL and that the data platform checks while it writes rows. Rows that break the rule never land in the table, because the build fails and is rolled back.
1.Constraints move validation into the data platform
A constraint is a rule that the data platform itself checks as rows are written. You declare it in a model's YAML. dbt doesn't evaluate it with a query of its own. Instead, dbt will include the column names, data types, and constraints in the DDL statements it submits to the data platform, so the warehouse checks every row as it creates or updates the table. When a row breaks the rule, the table creation or update fails and the operation is rolled back.
The difference from a data test is timing. A data test validates a model's content after the model is built, so it inspects a table that already exists. A constraint is checked at build time, and a violation stops the build itself. Here is the Postgres DDL that dbt renders for an id column with not_null, primary_key and check constraints. All three rules end up on the column definition, where Postgres enforces them on the insert that follows.
create table "database_name"."schema_name"."constraints_example__dbt_tmp"
(
id integer not null primary key check (id > 0),
customer_name text,
first_transaction_date date
)
;Checkpoint 1 of 8· Check yourself
You declare a not_null constraint on a column in YAML. What actually checks it?
dbt places constraints in the DDL and the platform enforces them as the table is built or updated. The preflight check compares names and types only, and data tests run after the build.
“dbt will include the column names, data types, and constraints in the DDL statements it submits to the data platform”Source: docs.getdbt.com
2.Prerequisites: a table or incremental model with an enforced contract
Constraints are only applied when two conditions hold. If either is missing, the YAML is accepted but nothing reaches the warehouse.
- Materialization. The model must be table or incremental. Constraints are never applied on ephemeral models or on models materialized as view.
- Contract. The model must declare and enforce a contract, so every column needs a data_type in YAML, not just the columns you constrain.
The contract requirement often surprises people. Adding not_null to one key column of a 40-column model means declaring all 40 columns with their types. The contract itself, meaning the name and type checks, its supported materializations and what counts as a breaking change, is covered under [model contracts](lesson:2.1). For constraints, what matters is that the contract is the vehicle that carries them into the DDL.
Views are a partial exception. A view can carry an enforced contract, but the contract only covers column names and data types. Views offer support for those, but not constraints, so a not_null declared on a view column is not applied.
models:
- name: dim_customers
config:
contract:
enforced: true
columns:
- name: customer_id
data_type: int
constraints:
- type: not_null
- name: customer_name
data_type: stringCheckpoint 2 of 8· Check yourself
A model is materialized as view with an enforced contract, and its order_id column declares a not_null constraint. What does the platform enforce?
Views support the name and type part of a contract, but constraints only apply to table and incremental models.
“views offer support for column names and data types, but not constraints”Source: docs.getdbt.com
Checkpoint 3 of 8· Exam question
A team runs its warehouse on Postgres and wants dbt to actively reject any build of `fct_orders` that loads a `customer_id` value with no matching row in `dim_customers`, not just document the relationship for humans reading the docs site. Which change to the contracted model's YAML best achieves this?
Correct answer: A — Add a `foreign_key` constraint on `customer_id` referencing `dim_customers(customer_id)`; Postgres enforces foreign keys at build time and rejects rows without a matching parent.
- A. This is correct because a foreign_key constraint on Postgres is both definable and enforced at build time, so dbt's compiled DDL causes the warehouse itself to reject any row whose customer_id has no matching parent row.
- B. This is incorrect for the stated goal because not_null only catches missing values; a customer_id could still be a non-null value that simply does not exist in dim_customers, and this constraint would let that row through.
- C. This is incorrect because a relationships test runs as part of dbt test, which is a separate step from the build itself, so an orphaned row can still be loaded into the table before the test ever flags it.
- D. This is incorrect because a unique constraint only rejects duplicate customer_id values within fct_orders; it has no mechanism for checking whether those values correspond to real rows in dim_customers.
3.Writing constraints at the column and model level
A constraint can be attached to a single column or to the model as a whole. The general rule is to define a single-column constraint directly under that column. Use the model level when the rule spans more than one column, such as a composite primary key, a multi-column foreign key, or a check that compares two columns. One case requires the model level: a model with multiple primary_key constraints has to define them there, because defining multiple primary keys at the column level is not supported.
Every constraint is built from the same few fields:
- type (required): one of not_null, unique, primary_key, foreign_key, check or custom.
- expression: free text that qualifies the constraint. Some types require it, for example a check needs its boolean condition.
- name (optional): a human-friendly name, supported by some data platforms.
- columns (model level only): the list of columns the constraint applies to.
# model-level constraints
constraints:
- type: primary_key
columns: [first_column, second_column, ...]
warn_unsupported: True # show a warning if unsupported
- type: foreign_key # multi_column
columns: [first_column, second_column, ...]
to: ref('my_model_to') | source('source', 'source_table')
to_columns: [other_model_first_column, other_model_second_columns, ...]
- type: check
columns: [first_column, second_column, ...]
expression: "first_column != second_column"
name: human_friendly_nameForeign keys take two extra inputs. to names the referenced relation with ref() or source(), and to_columns lists the primary or unique key columns in that relation. Because to uses ref, dbt records the dependency in the DAG and resolves the right schema in every environment. A hard-coded table name would point development builds at production. This syntax is available in dbt v1.9 and later.
Checkpoint 4 of 8· Fill the gap
Which key completes this column-level foreign key so that it names the matching column in the referenced model?
# column-level constraints
constraints:
- type: not_null
- type: unique
- type: foreign_key
to: ref('my_model_to') | source('source', 'source_table')
? : [other_model_column]
warn_unenforced: False # skips warning if supported but not enforcedto_columns lists the primary or unique key columns in the relation named by to. columns is the model-level list of columns the constraint applies to.
Sources1
4.What each platform defines and enforces
Writing a constraint in YAML does not mean your warehouse will reject rows that break it. Every constraint type falls into one of three categories on a given platform:
- Definable and enforced: the model won't build if the data violates it. - Definable and not enforced: the constraint is written into the DDL as metadata, and the model builds even if the data violates it. - Not definable: the platform cannot express the constraint at all.
Transactional databases enforce strictly, while cloud warehouses mostly record metadata. Postgres enforces everything in the ANSI SQL standard plus row-level check. Most analytical platforms enforce not_null and little else. On Snowflake, unique, primary_key and foreign_key are accepted but not verified on insert. If you specify rely in the constraint expression, Snowflake can use them for query optimization. Unenforced constraints can still be useful for integrating with legacy data catalogs or entity-relationship diagram tools.
| Platform | not_null | primary_key / foreign_key | unique | check |
|---|---|---|---|---|
| Postgres | Defined and enforced | Defined and enforced | Defined and enforced | Defined and enforced |
| Redshift | Defined and enforced | Defined, metadata only | Defined, metadata only | Not supported |
| Snowflake | Defined and enforced | Defined, metadata only | Defined, metadata only | Not supported |
| BigQuery | Defined and enforced | Defined, metadata only | Not supported | Not supported |
dbt warns whenever a constraint you wrote won't be enforced on your platform. Two optional fields, set on an individual constraint, turn those warnings off, and they behave differently:
- warn_unenforced: False silences the warning for a constraint the platform supports but doesn't enforce. The constraint is still included in the DDL.
- warn_unsupported: False silences the warning for a constraint the platform doesn't support. That constraint is left out of the DDL.
On Snowflake, for example, a check constraint triggers a warning and dbt skips it.
Checkpoint 5 of 8· Match them up
Match each constraint field to what it does
Tap a term, then the definition that fits it.
Both warn fields only silence warnings. The difference is that an unenforced constraint still goes into the DDL, while an unsupported one is skipped.
“warn_unsupported: False to skip warning on constraints that aren't supported by this data platform, and therefore won't be included in templated DDL.”Source: docs.getdbt.com
Checkpoint 6 of 8· Exam question
A team defines `primary_key`, `foreign_key`, `unique`, and `not_null` constraints on a contracted model that materializes on Snowflake. Which statements accurately describe what happens when this model is built?(Select 3)
Correct answers: A, B, F — The `not_null` constraint is enforced by Snowflake at build time, so a row containing a null in that column aborts the build.; The `primary_key` constraint is recorded as table metadata, but Snowflake does not reject a build that produces duplicate key values.; Declaring these four constraints still requires every column in the model to have both a `name` and a `data_type` set in the YAML.
- A. This is correct because not_null is one of the few constraint types Snowflake both defines and enforces at build time, so a null value in that column will cause the build to fail.
- B. This is correct because Snowflake records primary_key as metadata on the table for documentation and downstream tooling, but it does not run a uniqueness check that would block the build.
- C. This is incorrect because dbt does not issue a separate referential-integrity query for foreign_key on Snowflake; the constraint is written into the compiled DDL as metadata and nothing more happens at build time.
- D. This is incorrect because Snowflake's unique constraint, like primary_key and foreign_key, is metadata only on this platform and does not reject a build containing duplicate values.
- E. This is incorrect because Snowflake does record definable constraint types like not_null, primary_key, foreign_key, and unique in the compiled DDL; they are not silently discarded, even when only some of them are actively enforced.
- F. This is correct because an enforced contract requires a full column declaration regardless of platform, so every column needs a name and data_type before dbt will compile the model at all.
Sources1
5.Choosing between a constraint and a data test
A constraint can replace a data test, but only when it is actually enforced. Swapping a not_null test for a not_null constraint guarantees the check at build time and probably needs less compute, because there is no separate query afterward. The swap is only safe when all three of these hold:
- The platform supports and enforces that constraint. Most platforms only enforce not_null.
- The model is materialized as table or incremental.
- The model has a full contract, with the name and data_type of every column.
When those conditions don't hold, the test stays. The usual case is a primary_key on Snowflake, Redshift or BigQuery. The platform rejects null keys but accepts duplicates, so the uniqueness half of the rule still needs a unique test. Tests also have strengths that constraints lack. You can set custom severity thresholds, query the already-built model to debug a failure, and store the failing records in the warehouse.
columns:
- name: id
data_type: integer
description: hello
constraints:
- type: not_null
- type: primary_key # not enforced -- will warn & include
- type: check # not supported -- will warn & skip
expression: "id > 0"
data_tests:
- unique # need this test because primary_key constraint is not enforcedCheckpoint 7 of 8· Check yourself
On Snowflake, a contracted table model declares primary_key on order_id. Upstream starts sending duplicate order_id values. How do you make sure the duplicates are caught?
Snowflake records primary_key as metadata and only checks its not-null part, so a unique test has to catch duplicates. rely only lets the optimizer use the constraint, and warn_unenforced only silences the warning.
“need this test because primary_key constraint is not enforced”Source: docs.getdbt.com
Checkpoint 8 of 8· Exam question
A contracted incremental model has run successfully for months. After a new sanctioned column is added to its YAML, the next scheduled build fails on a schema mismatch against the enforced contract. The team wants this and future column additions to build cleanly without loosening the contract. Which action should they take?
Correct answer: A — Set `on_schema_change: append_new_columns` in the model's config so dbt reconciles newly declared contracted columns into the existing incremental table.
- A. This is correct because incremental models only support constraints when on_schema_change is set to append_new_columns or fail, and append_new_columns specifically lets new contracted columns be reconciled into the existing table without failing.
- B. This is incorrect because it satisfies the immediate symptom by abandoning the goal itself; the team explicitly wants to keep the contract enforced, not disable the validation that is catching the schema drift.
- C. This is incorrect because view materializations only support column names and data types, not constraints, so switching to view would silently drop the primary_key and foreign_key guarantees the team relies on.
- D. This is incorrect because forcing a full refresh on every run defeats the purpose of an incremental model and does not address the underlying on_schema_change configuration that caused the mismatch in the first place.
Sources2
Exam traps
Each one states something that sounds right. Open it to see what is actually true.
1.Adding constraints to a view model makes the warehouse reject bad rows.Why is that wrong?
Constraints only apply to table and incremental models. A view with an enforced contract gets name and type checks but no constraints.
Covered in Prerequisites: a table or incremental model with an enforced contract
2.You can add a not_null constraint to one column without declaring a contract or types for the other columns.Why is that wrong?
Constraints need an enforced contract, which requires a data_type for every column in the model.
Covered in Prerequisites: a table or incremental model with an enforced contract
3.A primary_key constraint on Snowflake guarantees that the key column is unique.Why is that wrong?
Snowflake only checks not-null, including the not-null part of a primary key. Uniqueness is metadata only.
Covered in What each platform defines and enforces
4.A composite key can be declared by putting primary_key under each of its columns.Why is that wrong?
Multiple primary_key constraints must be declared once at the model level, with a columns list.
Covered in Writing constraints at the column and model level
Sources
Every claim above is drawn from one of these pages, quoted as it was written on the date shown.
- 1.
“If the validation fails, the table creation or update fails, the operation is rolled back, and you will see a clear error message.”
↩︎ Constraints move validation into the data platform“Constraints only work on table and incremental models.”
↩︎ Prerequisites: a table or incremental model with an enforced contract“This means you need to explicitly define the data_type for every column in your model's schema configuration.”
↩︎ Prerequisites: a table or incremental model with an enforced contract“As a general rule, we recommend defining single-column constraints directly on those columns.”
↩︎ Writing constraints at the column and model level“If you define multiple primary_key constraints for a single model, those must be defined at the model level.”
↩︎ Writing constraints at the column and model level“This syntax for defining foreign keys uses ref, meaning it will capture dependencies and works across different environments.”
↩︎ Writing constraints at the column and model level“a model can still build even if building the model violates the constraint. This constraint exists for metadata purposes only.”
↩︎ What each platform defines and enforces“you may optionally instruct Snowflake to use them for query optimization by specifying rely in the constraint expression field.”
↩︎ What each platform defines and enforces“The constraint will be included in templated DDL.”
↩︎ What each platform defines and enforces“Redshift currently only enforces not null constraints; all other constraints are metadata only.”
↩︎ What each platform defines and enforces“When enforced, a constraint guarantees that you will never see invalid data in the table materialized by your model.”
↩︎ Key concept“Constraints are never applied on ephemeral models or those materialized as view.”
↩︎ Exam trap 1“To use constraints, your model must declare and enforce a contract.”
↩︎ Exam trap 2“only the not null (and the not null property of primary key) are actually checked at present.”
↩︎ Exam trap 3“Defining multiple primary_key constraints at the column level is not supported.”
↩︎ Exam trap 4“warn_unsupported: False to skip warning on constraints that aren't supported by this data platform, and therefore won't be included in templated DDL.”
↩︎ Checkpoint“need this test because primary_key constraint is not enforced”
↩︎ Checkpoint - 2.
“dbt will include the column names, data types, and constraints in the DDL statements it submits to the data platform”
↩︎ Constraints move validation into the data platform“Data Tests are a more flexible mechanism for validating the content of your model after it's built.”
↩︎ Constraints move validation into the data platform“views offer support for column names and data types, but not constraints”
↩︎ Prerequisites: a table or incremental model with an enforced contract“This has the advantage of guaranteeing the validation at build time, and it probably requires less compute (cost) in your data platform.”
↩︎ Choosing between a constraint and a data test“Making sure that your data platform can support and enforce the constraint that you need. Most platforms only enforce not_null.”
↩︎ Choosing between a constraint and a data test“They are easier to debug after finding failures because you can query the already-built model, or store the failing records in the data warehouse.”
↩︎ Choosing between a constraint and a data test