dbt logo

Eleven Copies of One Enum

Grep a mature dbt project for `accepted_values` and count the hits on your order status field. In a mature project, the number is rarely one. The list appears in the staging model that first cleans the column, in two intermediate models, in the fact table, in three marts built for three teams, and in another four models a second squad wrote when it needed the same field and copied the nearest example it could find.

Eleven Copies of One Enum

Grep a mature dbt project for accepted_values and count the hits on your order status field. In a mature project, the number is rarely one. The list appears in the staging model that first cleans the column, in two intermediate models, in the fact table, in three marts built for three teams, and in another four models a second squad wrote when it needed the same field and copied the nearest example it could find.

Eleven blocks. Nine of them agree:

# models/marts/fct_orders.yml
columns:
  - name: status
    data_tests:
      - accepted_values:
          values: ["placed", "shipped", "completed", "returned"]

Two do not:

# models/staging/stg_returns.yml
columns:
  - name: order_status
    data_tests:
      - accepted_values:
          values: ["placed", "shipped", "completed", "returned", "return_pending"]

Nothing is broken. dbt build is green in both cases, because each list is true of the rows in its own model. But the project now contains two incompatible statements about what an order status is, and no file in the repository is more authoritative than the other. When someone adds a sixth value next quarter, the question "where do I change this?" has eleven answers, and the honest one is "in as many of them as you can find."

Now widen the frame, because the eleven copies are the small version of the problem. The same vocabulary also exists in the JSON Schema your orders API publishes, in the Avro subject behind the order-events topic, in the submission format a regulator or an industry consortium expects from you, in the field mapping a partner integrated against last year, and in the term list your catalog shows to analysts. Six representations of one idea, each hand-maintained by different people on different release cycles, each drifting from the warehouse it claims to describe.

dbt is not the thing that is failing here

It is worth being exact about this, because the diagnosis matters more than the complaint. dbt is very good at its job. Transformations as reviewed, versioned code; generic tests that catch the failures they were written for; model contracts that stop a column from silently changing type; docs that render the estate as a browsable site. Every one of those is the right tool for the ground it covers, and a team running them is far ahead of a team that is not.

The limit is not a defect in dbt's design. It is a boundary of what dbt is for. A dbt project compiles to exactly one artifact class: tables and views in one warehouse, plus dbt's own metric definitions on top of them. That is the deliverable. Everything else an organization owes its consumers — the API schema, the stream subject, the regulatory submission, the partner contract, the catalog vocabulary — is out of scope by construction, and therefore lives somewhere else, maintained by hand, with no mechanical relationship to the models it describes.

Meaning has no slot to live in

Inside the project, the same boundary shows up as three slots that were never designed to hold definitions.

description is free text. It is the right place for a sentence a human reads, and the wrong place for a fact a machine has to act on. Nothing validates it, nothing links it to anything, and two descriptions of the same column in two models can contradict each other for years without consequence.

meta is an unvalidated key-value bag. Anything can go in it, which means nothing in it can be relied on. There is no shared vocabulary for what a key means, so meta.owner in one file and meta.owning_team in another are, to every tool that reads them, unrelated strings.

accepted_values is a flat list of strings attached to one column in one model. It carries no definition of what each value means, no hierarchy — returned and return_pending are siblings as far as dbt is concerned, though one is plainly a stage of the other — and no external identity. There is nowhere to record that this column is not merely called status but is a particular concept, the one a standards body or a published ontology already named and defined. So the list gets retyped into every model that touches the field, and the eleven copies begin.

None of that is a bug. It is a transformation framework being asked to be a terminology system, which it never claimed to be.

Why the usual fixes stop short

Teams centralize the enum in a project variable or a macro, which genuinely fixes the eleven copies — inside this repository. It does nothing for the API team, the streaming team, or the regulator, and the centralized list still carries no definitions, no hierarchy, and no external identity. It is one copy instead of eleven, which is better, but it is still a copy.

Teams write a doc block, which is free text again. Teams write a wiki page, accurate on the day it is written. Teams buy a catalog, which reads the warehouse after the fact and describes what it finds — but describing is not deciding, and a catalog that disagrees with production merely looks wrong rather than authoritative.

The common failure is direction. All of these read the estate and try to explain it. What is missing runs the other way: a governed statement of meaning that consumers are published from.

What a publication contract is

A publication contract has three properties, and dbt sits inside it as a consumer rather than only as a source.

Meaning is governed once, outside the repository. An order status becomes a vocabulary with named terms, each with a definition, arranged in whatever hierarchy is real, owned by people who can be asked. A column is not merely typed; it can be bound to the term it represents in an external standard, so its identity survives leaving your warehouse. Changing any of that is a deliberate human act, not a side effect of someone editing SQL.

That governed meaning is then published into each consumer's native form, mechanically. For dbt, CoreModels emits one model property file per model, written to sit beside that model's own .sql, carrying an enforced contract, a data_type on every column, not_null constraints and uniqueness tests from the recorded checks, relationships tests from governed references, and accepted_values generated from the governed vocabulary — which is why one vocabulary produces one identical list in all eleven models, and why adding a sixth term becomes a single edit followed by a regeneration. Bound standard terms ride out twice: structurally, under a meta.coremodels.maps_to block on the model and on each column, and appended to the column description, because the description is the only slot dbt's persist_docs carries into the warehouse column comment. The same governed vocabulary is what the JSON Schema enum, the Avro symbols, and the other emitted representations are built from, so the six hand-maintained copies collapse into six generated ones.

And the contract stays a proposal until a person accepts it. CoreModels never connects to your warehouse, never runs dbt, and never writes into your repository. It takes artifacts you already produce — manifest.json, optionally the catalog — and it returns files. Your existing pull request flow lands them, reviewed like any other change, which is exactly where a change to what a column means belongs.

Importing a manifest on its own gets you a description of the estate: models, columns, tests, lineage, faithfully recorded. The value arrives when a human governs meaning on top of it — writes the definitions, names the vocabulary, binds the terms — and publishes that back down into every consumer that had been maintaining its own copy.

Eleven files disagreeing about one enum is not a discipline problem. It is what happens when meaning has no home and no way to travel.

To try this against your own project, start with the CoreModels dbt quickstart in our docs — it walks the import, the audit, and the generated contracts end to end.