Seeds: Loading Static Reference Data
What a dbt seed actually is, the CSV-in-warehouse-out model, what seeds are genuinely good for versus what they are not, column type overrides with seed-column-types, dbt seed versus dbt seed --full-refresh, and a full worked country-region lookup example.
The One dbt Object That Starts as a File, Not a SELECT
Every other object in a dbt project — a model, a snapshot, an ephemeral CTE — starts life as aSELECT statement over data that already exists somewhere in the warehouse. A seed breaks that pattern entirely: it is a plain .csv file, living in the seeds/ directory of your project, checked into version control right alongside your models, that dbt loads directly into the warehouse as a real table via thedbt seed command. There is no upstream source table for a seed to SELECTfrom — the CSV file itself is the source of truth.
country_code,country_name,region
US,United States,north_america
CA,Canada,north_america
MX,Mexico,north_america
GB,United Kingdom,europe
DE,Germany,europe
FR,France,europe
JP,Japan,asia_pacific
AU,Australia,asia_pacific
BR,Brazil,south_americaRunning dbt seed reads every .csv file in seeds/, infers a schema from the file's contents, and creates (or replaces) one table per file in the warehouse — the table name matches the file name by default, soseeds/country_region_mapping.csv becomes a table literally calledcountry_region_mapping. Once that table exists, it can be referenced from any model using {{ ref('country_region_mapping') }}, exactly the same ref()call used for any other model — a seed participates in the DAG, gets picked up bydbt docs generate, and can even carry its own schema.yml tests, all identically to a model.
$ dbt seed
Running with dbt=1.8.0
Found 1 seed file
1 of 1 START seed file analytics.country_region_mapping ... [RUN]
1 of 1 OK loaded seed file analytics.country_region_mapping ... [INSERT 9 in 0.41s]
Done. PASS=1 WARN=0 ERROR=0 TOTAL=1select
o.order_id,
o.customer_id,
c.country_code,
r.region
from {{ ref('stg_orders') }} o
join {{ ref('dim_customers') }} c on o.customer_id = c.customer_id
left join {{ ref('country_region_mapping') }} r on c.country_code = r.country_codeThe mental model to hold onto: a seed is dbt's answer to "I need a small table of values that doesn't come from any operational system, and I want to manage it the same way I manage everything else in this project — version controlled, code reviewed, and referenced withref()." It is the one deliberate exception to "every dbt object is a transformation over existing data," and that exception exists specifically for data that has no natural upstream source to transform in the first place.
The seed table's name comes from the file name, and that name is not trivial to change later
Because a seed table's name defaults directly to its CSV file's name, renamingcountry_region_mapping.csv to country_to_region.csv later does not rename the existing warehouse table — it creates a brand-new one under the new name on the nextdbt seed, and leaves the old table sitting in the warehouse until someone manually drops it, since dbt has no way to know the new file is meant to replace the old table rather than exist alongside it. Every model that referenced the seed by its old name via ref()also needs updating in the same change, exactly as it would if a model file were renamed.
1. rename seeds/country_region_mapping.csv to seeds/country_to_region.csv
2. update every ref('country_region_mapping') call to ref('country_to_region')
3. after the next dbt run/build, manually drop the old, now-orphaned
country_region_mapping table -- dbt does not do this automatically,
since it has no way to know the old table is meant to be retired
rather than a second, unrelated seedThis is worth knowing before it happens by surprise: an orphaned table left behind by a seed rename looks, to anyone browsing the warehouse later, like a real table that might still matter, and cleaning it up requires someone to notice it is no longer referenced by ref()anywhere in the project at all — exactly the kind of quiet warehouse clutter that a habit of checking dbt docs generate's lineage graph (covered in the documentation module) for orphaned nodes helps catch.
Small, Mostly-Static, Manually-Curated Reference Data
Seeds earn their place for exactly one category of data: small lookup or reference tables that change rarely, aren't produced by any of the company's own operational systems, and are naturally maintained by a human editing a spreadsheet or CSV rather than by an application writing rows into a database. The country-to-region mapping from Part 01 is the canonical example — nobody's production application generates that mapping; a person decided it once, and it changes maybe once or twice a year, if that.
| Good seed candidate | Why it fits |
|---|---|
| Country code → region mapping | A fixed, small, human-curated list — dozens to low hundreds of rows, changes essentially never. |
| A manually curated list of company holiday dates | Decided by HR or ops once a year, has no natural source system, and needs to be reviewable in a pull request like any other business logic. |
| A marketing-team-maintained list of UTM campaign categories | The marketing team, not an application, decides what counts as "paid_social" versus "affiliate" — a spreadsheet-shaped decision that seeds turn into a queryable table. |
| A small mapping of internal product SKU codes to human-readable names | Low cardinality, rarely changes, and having it in version control means a rename is code-reviewed like any other change to reporting logic. |
The common thread across every good seed candidate: the data's source of truth is a decision a person made, not an event a system recorded. A country-to-region mapping isn't measured or observed anywhere — someone decided Mexico counts as North America for this company's reporting purposes, wrote that decision down, and a seed is exactly the right place for a decision like that to live, because it puts the decision in version control, subject to the same pull request review as everything else in the project, rather than buried in a spreadsheet nobody remembers exists.
Size and change frequency are the two dials that matter
There's no hard row-count limit dbt enforces on a seed, but in practice, seeds are meant for small data — typically well under a few thousand rows. This isn't an arbitrary style preference: a seed is loaded in full, from a plain text file, on every dbt seed run, and a CSV file large enough to strain that process (megabytes of data, tens of thousands of rows) is almost always a sign the data actually belongs in a real ingestion pipeline instead, which Part 03 covers directly.
The Single Most Important Scope Boundary: Large or Frequently-Changing Data
This is the single most important thing to get right about seeds, because getting it wrong doesn't fail loudly — it just quietly turns your dbt project into an accidental, poorly-suited data pipeline. Seeds are not for large datasets, and they are not for data that changes on any kind of regular operational cadence. If data is measured in tens of thousands of rows, or updates daily, hourly, or in real time, it does not belong in seeds/ — it belongs behind an actual ingestion tool (Fivetran, Airbyte, a custom extract-load process, a streaming connector) landing in a raw table that dbt then reads via source(), exactly like every other piece of source data in the project.
| Bad seed candidate | Why it does not fit, and what to use instead |
|---|---|
| A daily export of all customer orders | Not static, not small, and it already has a natural source system — the orders database itself. This belongs behind a real ingestion pipeline and a source(), not a seed. |
| A weekly product catalog export with 50,000 SKUs | Row count and update frequency both disqualify it — a seed reloaded weekly by hand, at that size, is a manual process standing in for what an actual scheduled ingestion job should be doing. |
| Customer records exported from a CRM | Has a real, authoritative source system (the CRM) that should be the thing dbt ingests from directly, not a CSV snapshot someone remembers to re-export. |
| Anything containing PII at meaningful scale | A CSV in a git repository has none of the access controls, encryption, or audit logging a real data platform applies to sensitive data at the ingestion layer. |
The failure mode to watch for is subtle: a seed that starts out genuinely small and static — a country list with 50 rows, unlikely to ever change — is the right call. The same seed six months later, after someone has been quietly appending new rows to it by hand every week because "it was already a seed, so I just added rows," is now standing in for a real ingestion pipeline without anyone having made that decision on purpose. The tell is change frequency creeping upward without anyone re-evaluating whether a seed is still the right tool — worth actively watching for, not just a one-time judgment call made when the seed was first created.
ref() to it for a source() — a mechanical, bounded change. The Zillow example later in this module walks through exactly this kind of migration once a seed had clearly outgrown its original scope.Why this boundary matters more than it might seem
Getting this scope wrong has a specific, recurring cost: every seed lives in version control and is rebuilt from a flat file on every dbt seed run, which means every update to that data requires someone to manually edit a CSV, open a pull request, get it reviewed, and merge it — a reasonable amount of ceremony for a country list that changes twice a year, and a genuinely painful, unscalable process for anything that needs to be updated on any kind of regular schedule. Real ingestion tools exist precisely to automate the "get external data into the warehouse reliably and on schedule" problem; reaching for a seed instead reintroduces manual toil that ingestion tooling was built to eliminate.
dbt seed vs dbt seed --full-refresh
dbt seed, run with no flags, loads every CSV in seeds/ into the warehouse. By default, this behaves like a full replace each time — dbt drops (or truncates, depending on the adapter) and recreates each seed table from the current contents of its CSV file on every run, which means a row you deleted from the CSV since the last run is genuinely gone from the table after the next dbt seed, not left behind as a stale row.
# Load every CSV in seeds/
$ dbt seed
# Load just one seed file, by its resulting table name
$ dbt seed --select country_region_mappingdbt seed --full-refresh exists for a slightly different, more forceful case: rebuilding the seed table completely from scratch, including recreating it if its column types are being changed via seed-column-types (Part 05) after the table already exists. On most adapters, a plain dbt seed already fully replaces the table's contents, so--full-refresh is most useful specifically when a column's configured type has changed and the existing table's schema needs to be dropped and recreated to match, not merely have its rows replaced.
# After adding or changing a seed-column-types override for a column,
# force dbt to drop and recreate the table with the new column type,
# rather than assuming the existing table's schema is still correct
$ dbt seed --full-refresh --select country_region_mapping| Command | What it does | When to use it |
|---|---|---|
| dbt seed | Loads every CSV in seeds/, replacing each table's contents with the CSV's current rows. | The routine, default way to load or refresh seed data — covers the large majority of everyday seed updates. |
| dbt seed --select <name> | Loads just one specific seed file. | Iterating on a single seed without waiting for every other seed in the project to reload. |
| dbt seed --full-refresh | Forces a full drop-and-recreate of seed tables, ensuring schema changes (like a new seed-column-types override) actually take effect. | After changing a column's configured type, or when you suspect a seed table's schema has drifted from its current config. |
{{ ref('country_region_mapping') }} depends on that seed exactly like it would depend on another model — dbt's DAG treats seeds as first-class nodes. Runningdbt build (rather than dbt run plus a separate dbt seed) picks this up automatically, loading seeds in the correct dependency order alongside models, tests, and snapshots, the same way dbt build orders everything else in the project.seed-column-types: When dbt's Automatic Type Inference Gets It Wrong
When dbt loads a CSV, it has to decide a column type for every column, since a flat text file has no type information of its own — every value in a CSV is, at the file format level, just text. dbt infers types by inspecting the actual values in each column: a column where every value looks like a whole number becomes an integer type, a column where every value looks like a decimal becomes a float or numeric type, and anything else becomes a string.
This inference is usually right, and usually invisible — most seed columns are exactly what they look like. It goes wrong in one specific, common, and genuinely damaging way: a column of United States ZIP codes, where some values have a leading zero (Massachusetts and other New England zip codes commonly start with 0, like 02134), gets inferred as an integer column because every value in it does look like a whole number to dbt's type inference — and an integer has no concept of a leading zero. The moment that column is loaded as an integer,02134 silently becomes 2134, and the data is now wrong in a way that isn't obvious from looking at row counts or a quick sanity check.
zip_code,city,state
02134,Boston,MA
90210,Beverly Hills,CA
00501,Holtsville,NY
73301,Austin,TX-- dbt infers zip_code as an integer column, since every value
-- looks numeric. The loaded table actually contains:
zip_code | city | state
---------|----------------|------
2134 | Boston | MA
90210 | Beverly Hills | CA
501 | Holtsville | NY
73301 | Austin | TX
-- 02134 and 00501 have silently lost their leading zeros --
-- a join against this column using a properly formatted 5-digit
-- zip code string will now simply fail to match those rowsseed-column-types, configured in dbt_project.yml (or per-seed inschema.yml using column_types), tells dbt to skip inference for a specific column and force a specific warehouse type instead — forcing zip_code to a string type preserves it exactly as written in the CSV, leading zeros included.
seeds:
my_project:
zip_code_reference:
+column_types:
zip_code: varchar(5)seeds:
- name: zip_code_reference
config:
column_types:
zip_code: varchar(5)
columns:
- name: zip_code
description: US ZIP code. Forced to varchar(5) to preserve leading zeros lost by automatic type inference.
tests:
- unique
- not_nullNote that this second form also demonstrates something worth remembering: a seed'sschema.yml entry looks exactly like a model's — it can carry a descriptionand tests the same way, since a seed is a full first-class dbt node once loaded, not a second-class object with reduced capabilities.
$ dbt seed --full-refresh --select zip_code_reference
-- the recreated table now correctly contains:
zip_code | city | state
---------|----------------|------
02134 | Boston | MA
90210 | Beverly Hills | CA
00501 | Holtsville | NY
73301 | Austin | TXseed-column-types override and then running a plaindbt seed is a common mistake — on many adapters, this only reloads rows into the table's existing schema rather than altering the column's type, so the fix appears to do nothing.dbt seed --full-refresh, exactly as shown above, forces the table to be dropped and recreated with the newly configured type, which is what actually applies the fix.| Symptom | Likely cause | Fix |
|---|---|---|
| A ZIP code, phone number, or ID column loses leading zeros | dbt inferred an integer type from all-numeric-looking string values. | Force the column to a string type (varchar) via seed-column-types, then dbt seed --full-refresh. |
| A column of small whole numbers loaded as a floating-point type | A stray decimal value somewhere in the column (even one row) causes dbt to infer a float type for the whole column. | Force the column to an integer type explicitly, or clean the offending value in the CSV. |
| A boolean-looking column (TRUE/FALSE, yes/no) loaded as text instead of a real boolean | dbt's inference did not recognize the specific text values used as boolean literals for your warehouse. | Force the column to boolean via seed-column-types, or standardize the CSV's literal values to ones your warehouse recognizes natively. |
Custom Schemas, Delimiters, and Quoted Column Names
Beyond seed-column-types, a handful of other seed-specific configs come up regularly enough in real projects to be worth knowing before you hit them for the first time. None of these are exotic — they mirror the same configuration patterns already familiar from models, applied to the one part of a seed's behavior that is genuinely different: how it interprets and loads a flat file.
+schema — building seeds into their own dedicated schema
By default, a seed builds into the same schema as the rest of the project's models. Many teams prefer to keep reference-data seeds visibly separate from transformed models, so an analyst browsing the warehouse can immediately tell "this table is a maintained lookup, not a computed output" just from its schema. A +schema config, set in dbt_project.ymlunder the seeds: key, does exactly this — and it follows the samegenerate_schema_name macro behavior covered in the Jinja and macros module, so a custom seed schema still gets the same dev/prod naming treatment as a custom model schema would.
seeds:
my_project:
+schema: reference_dataquote_columns — handling column names that need quoting
Some CSV files, especially ones exported from a spreadsheet tool or another system, have column headers that don't match the target warehouse's default identifier rules — a header with a space, a reserved SQL keyword used as a column name, or mixed case on a warehouse that otherwise lowercases identifiers by default. quote_columns tells dbt whether to wrap each column name in quotes when generating the DDL to create the seed table, preserving it exactly as written in the CSV header rather than letting the warehouse's default identifier normalization silently reshape it.
seeds:
my_project:
campaign_category_mapping:
+quote_columns: trueWithout this, a header like Campaign Category (with a space) can fail to load cleanly, or load successfully but under a warehouse-mangled column name nobody expected, which then silently breaks any model referencing that column by its original name.
delimiter — for seed files that aren't comma-separated
Despite the name CSV (comma-separated values), not every flat file dbt needs to load actually uses a comma as its separator — a file exported from certain legacy systems, or one that intentionally uses a different delimiter because the data itself contains commas, might use a tab, a pipe, or a semicolon instead. The delimiter config tells dbt which character actually separates columns in that specific seed file.
seeds:
my_project:
legacy_region_codes:
+delimiter: "|"region_code|region_name|active
NA1|North America|true
EU1|Europe|true
AP1|Asia Pacific|true| Config | What it controls | Typical reason to set it |
|---|---|---|
| +column_types | Force a specific column to a specific warehouse type instead of relying on inference. | Preserving leading zeros, forcing a numeric-looking column to stay a string, or fixing an incorrectly inferred float/boolean (Part 05). |
| +schema | Which schema the seed builds into. | Keeping reference-data seeds visibly separate from computed models in the warehouse. |
| +quote_columns | Whether column names are quoted in the generated DDL, preserving exact casing/spacing. | CSV headers with spaces, reserved keywords, or casing that would otherwise be silently normalized. |
| +delimiter | Which character separates columns in the file. | A source file that is tab-, pipe-, or semicolon-delimited instead of comma-delimited, despite the .csv extension. |
seeds: key in dbt_project.yml, nested by project name and then by seed name, exactly the way model configs nest undermodels:. A seed-level override always takes precedence over a project-wide default set higher up the same nesting structure — the identical override-by-specificity behavior models already follow.Three Ways dbt Deals With Data It Didn't Compute, and When Each One Fits
Seeds are one of three distinct dbt mechanisms for dealing with data that isn't produced by aSELECT over another dbt model, and it is easy to reach for the wrong one simply because they can look superficially similar — all three end up as queryable tables in the warehouse, all three can be ref()'d or source()'d from a model. The right choice depends entirely on where the data actually comes from and how it changes over time, not on which one happens to be most familiar or easiest to set up first.
| Mechanism | Where the data originates | How it changes over time | Right fit |
|---|---|---|---|
| Seed | A file you write and commit yourself — no upstream system produces it. | Rarely, and only when a person deliberately edits the file. | Small, static, human-curated reference data (Part 02). |
| Source | An external system's own tables, landed into the warehouse by a separate ingestion tool. | Whenever the ingestion tool runs — often continuously or on a tight schedule. | Any data an operational system already owns and produces on its own. |
| Snapshot | An existing dbt source or model, captured over time to preserve its history. | Every time the snapshot runs, recording what changed since the last capture. | Tracking how a mutable table's rows changed over time — the concern of the next module in this track. |
The distinguishing question for seeds versus sources is almost always "does an operational system already own this data?" A country-region mapping has no operational owner — no application's database has a table for it, because it isn't the kind of fact an application transaction would ever produce. A customer's shipping address, by contrast, is owned by the e-commerce platform's own database, and even though it might occasionally be exported as a one-off CSV for a specific analysis, its real, authoritative home is that operational system — which is exactly why it should be ingested as a source, not committed as a seed.
Is there an operational system that owns this data?
1. "Which US states count as the Northeast region for our reporting?"
-> No operational owner. This is our own business decision.
-> SEED.
2. "What is every customer's current shipping address?"
-> Yes -- the e-commerce platform's own customers table owns this.
-> SOURCE, ingested from the operational database.
3. "What was each customer's shipping address on the day of each
historical order, even after they later moved?"
-> The current source table only has TODAY's address. Capturing
how it changed over time is a different problem.
-> SNAPSHOT, built on top of the source from case 2.Snapshots deserve one further clarification here, because seeds and snapshots are sometimes confused simply for both being "not quite a normal model." A snapshot never originates data — it always builds on top of an existing source or model and adds change-tracking on top of it. A seed, by contrast, is the actual origin of its data; there is no earlier dbt object a seed is derived from. If you find yourself wanting to "snapshot" a seed to track how its rows changed over time, that is usually itself a sign the underlying data has started changing often enough that Part 03's scope boundary is worth revisiting — genuinely static reference data has no meaningful history to snapshot in the first place.
Treating a Seed Change Like Any Other Code Change
Because a seed's CSV lives in the same repository as every model, a change to it flows through exactly the same review and deployment process as a change to any model's SQL — which is a real advantage over a spreadsheet or an ad hoc manually maintained table, but only if the team actually treats seed changes with the same discipline as any other change, rather than as a special, lower-stakes category of edit that doesn't need the same scrutiny.
What a pull request touching a seed should include
A well-reviewed seed change looks like any other well-reviewed dbt change: the diff shows exactly which rows were added, removed, or changed (git's line-level diffing works on a CSV the same way it works on SQL), the accompanying schema.yml tests still make sense against the new data, and — critically — a reviewer actually checks whether the change is still within a seed's appropriate scope from Part 03, rather than rubber-stamping "just a data file" changes without the same scrutiny a logic change would get.
--- a/seeds/country_region_mapping.csv
+++ b/seeds/country_region_mapping.csv
@@ -8,3 +8,4 @@ BR,Brazil,south_america
AR,Argentina,south_america
+NZ,New Zealand,asia_pacific
+CL,Chile,south_americaA reviewer looking at this diff should be checking the same things they'd check on any change touching business logic: does adding New Zealand to asia_pacific match how the business actually wants it categorized (some companies group Australia and New Zealand as their own "oceania" bucket instead), and does the existing accepted_values test on theregion column still cover every value now in use. A seed change that silently introduces a new region value with no corresponding update to that test is a real gap — the test would need to be updated in the same pull request, not treated as someone else's problem to notice later.
CI runs dbt seed exactly like any other node
A CI pipeline running dbt build against a pull request's changes rebuilds seeds alongside models automatically, in dependency order — there's no special CI step required for seeds beyond what already runs for the rest of the project. This matters because it means a seed change is validated against the exact same tests, and against models that actually join against it, in the same CI run that validates everything else — a broken accepted_values test from the New Zealand example above would fail the same CI check that catches a broken test on any model.
1 of 3 START seed file analytics_ci.country_region_mapping ... [RUN]
1 of 3 OK loaded seed file analytics_ci.country_region_mapping ... [INSERT 16 in 0.38s]
2 of 3 START test accepted_values_country_region_mapping_region ... [RUN]
2 of 3 PASS accepted_values_country_region_mapping_region ......... [PASS in 0.22s]
3 of 3 START sql view model analytics_ci.fct_revenue_by_region ... [RUN]
3 of 3 OK created sql view model analytics_ci.fct_revenue_by_region [SELECT 4 in 0.51s]
Done. PASS=2 WARN=0 ERROR=0 FAIL=0 TOTAL=2One operational detail worth planning for deliberately: because dbt seed replaces a seed table's contents on every run, a seed built as part of a CI job against an ephemeral or shared CI schema behaves exactly like it would in any other environment — nothing special happens just because it's CI. The only thing worth double-checking is that whatever schema CI builds into is genuinely isolated from production, the same requirement that applies to every other node dbt builds during CI, not something unique to seeds.
| Team practice | Why it matters for seeds specifically |
|---|---|
| Require a reviewer on any seed CSV change | A seed encodes a real business decision — a country's regional classification, a campaign category — and deserves the same scrutiny as a change to the SQL implementing similar logic. |
| Keep accepted_values tests in sync with the seed's actual contents | A seed change that introduces a new categorical value with no matching test update creates a silent gap where the test no longer reflects the seed's true set of valid values. |
| Re-run dbt build (not just dbt seed) in CI | Confirms not just that the seed loads, but that every model and test depending on it still behaves correctly against the changed data. |
| Periodically audit seed row counts and update frequency | The concrete, actionable version of Part 03's scope-boundary check — a rising row count or update frequency is the measurable signal that a seed may be outgrowing its intended scope. |
country_region_mapping End to End: CSV, Config, and a Mart Join
Putting the whole module together: a small, genuinely static reference seed, itsschema.yml configuration including a forced column type and tests, and a mart model that joins against it exactly the way it would join against any other model.
country_code,country_name,region
US,United States,north_america
CA,Canada,north_america
MX,Mexico,north_america
GB,United Kingdom,europe
DE,Germany,europe
FR,France,europe
ES,Spain,europe
IT,Italy,europe
JP,Japan,asia_pacific
AU,Australia,asia_pacific
IN,India,asia_pacific
SG,Singapore,asia_pacific
BR,Brazil,south_america
AR,Argentina,south_americaversion: 2
seeds:
- name: country_region_mapping
description: >
Static country-to-region mapping used across marketing and finance
reporting. Maintained by the data platform team; updates go through
a normal pull request against this CSV file. Not sourced from any
operational system — this mapping is a business decision, not an
observed fact.
config:
column_types:
country_code: varchar(2)
columns:
- name: country_code
description: ISO 3166-1 alpha-2 country code. Forced to varchar(2) so a code like "US" is never misinterpreted by type inference.
tests:
- unique
- not_null
- name: country_name
description: Full country name, for display purposes.
tests:
- not_null
- name: region
description: The region this company's reporting groups this country into. Not a standard geographic classification — this is our own internal grouping.
tests:
- not_null
- accepted_values:
values: ['north_america', 'europe', 'asia_pacific', 'south_america']with orders as (
select
order_id,
customer_id,
total_amount_cents
from {{ ref('fct_orders') }}
),
customers as (
select
customer_id,
country_code
from {{ ref('dim_customers') }}
),
regions as (
select
country_code,
region
from {{ ref('country_region_mapping') }}
)
select
regions.region,
count(distinct orders.order_id) as order_count,
sum(orders.total_amount_cents) / 100.0 as total_revenue_dollars
from orders
join customers on orders.customer_id = customers.customer_id
left join regions on customers.country_code = regions.country_code
group by regions.regionEvery piece here does real work. The seed itself holds the actual mapping decision, in a file any engineer can open and read directly. The schema.yml config forcescountry_code to a fixed-width string, which matters less for leading zeros here than for the more general principle — any code-like column benefits from an explicit type rather than leaving it to inference, since a future addition to the CSV (say, a numeric-looking country code from a different classification system) could otherwise silently change the inferred type for everyone. The accepted_values test on region guards against a typo in a future CSV edit — someone fat-fingering "europe " with a trailing space, or"Europe" with different casing, fails the test immediately on the nextdbt build rather than silently producing an ungrouped row in the revenue report. And the mart model itself treats the seed exactly like any other ref()'d table, with no special syntax required anywhere in the join.
fct_revenue_by_region uses a left join againstcountry_region_mapping, not an inner join. If a customer's country code isn't yet present in the seed — a new market the company just started operating in, before someone has added it to the CSV — an inner join would silently drop that customer's orders out of the report entirely. A left join keeps the order visible, with a null region, which is a far easier problem to notice and fix than orders quietly disappearing from a revenue total.Closing the loop: a singular test that catches an unmapped country before the left join hides it
The left join in fct_revenue_by_region is the right defensive choice for the report itself, but "the report doesn't crash" and "we noticed a country is missing from the mapping" are two different outcomes, and a left join alone only guarantees the first one. A nullregion sitting quietly in a group by result is easy to miss in practice — it just looks like one more row in a dashboard, not an alert. Closing that gap for real means adding an explicit check that a country actually being used in orders is not silently missing from the seed at all.
-- Fails if any customer's country_code, actually referenced by a
-- real order, has no matching row in country_region_mapping.
-- This is the check that turns a silent null region into a
-- visible, actionable test failure.
select distinct
customers.country_code
from {{ ref('fct_orders') }} orders
join {{ ref('dim_customers') }} customers
on orders.customer_id = customers.customer_id
left join {{ ref('country_region_mapping') }} regions
on customers.country_code = regions.country_code
where regions.country_code is nullThis is a cross-model check exactly of the shape the testing module's singular tests cover — it cannot be expressed as a column-level generic test, because it needs to join the seed against actual usage in the orders data, not just check the seed's own columns in isolation. Running this as part of dbt build means the very first order placed from a newly launched market fails this test immediately, with the specific missing country_code value right there in the failure output — rather than that market's revenue quietly sitting uncategorized infct_revenue_by_region until someone happens to notice the numbers look a little off.
| Defense | What it catches | What it does not catch |
|---|---|---|
| Left join in the mart model | Prevents orders from an unmapped country from disappearing entirely. | Does not alert anyone that a country is unmapped — the null region is silent unless someone looks for it. |
| accepted_values test on region | Catches a typo or unexpected value already present in the seed itself. | Does not catch a country_code that is missing from the seed altogether, since there is no row to check a value against. |
| Singular test joining orders against the seed | Actively fails when a country_code actually in use has no matching seed row at all. | Nothing — this is the check that closes the gap the other two leave open. |
A short checklist for the moment before you add a new seed
Putting the whole module together into something usable in the moment a new reference-data need comes up: before creating a new file in seeds/, it is worth running through a handful of quick questions, most of which are just Part 03's scope boundary and Part 07's origin-of-data test restated as a concrete pre-flight list.
- ✓Does any operational system already own this data? If yes, it belongs behind a source(), not a seed.
- ✓Is it genuinely small — comfortably a few hundred to low thousands of rows, not tens of thousands?
- ✓Will a person realistically update it only a handful of times a year, not on any kind of regular schedule?
- ✓Does any column contain values where leading zeros, exact casing, or a fixed width actually matter? If so, plan the seed-column-types override up front rather than discovering the bug after the fact.
- ✓Does the seed need at least a unique/not_null test on whatever functions as its natural key, and an accepted_values test on any column with a fixed, known set of valid values?
A "no" to the first question and comfortable "yes"es to the rest is the profile of a seed that will age well — reviewed occasionally, rarely touched, and never a source of surprise. A seed that fails more than one of these checks at the moment it's created is worth a second look before the file is even committed, since the cost of correcting a wrongly-scoped seed only grows the longer it sits in production being quietly relied upon by downstream models.
Five Misconceptions About dbt Seeds
Three Ways Real Teams Have Used — and Almost Misused — Seeds
Compass maintains a seed mapping ZIP codes to the internal "market" names its business teams use for reporting — a genuinely static, human-curated list with no natural source system, exactly the kind of data seeds are meant for. The seed initially had no column type override, and a handful of Northeast ZIP codes with leading zeros silently lost them on load, causing those specific ZIP codes to fail every downstream join against the properly formatted 5-digit ZIP codes stored elsewhere in the warehouse.
The fix was exactly the pattern in Part 05: forcing the ZIP code column to varchar(5)via seed-column-types and running dbt seed --full-refresh to apply the corrected schema. A handful of Massachusetts and Rhode Island markets, which had been quietly underreporting for weeks because their ZIP codes never matched anything, immediately started joining correctly.
A Zillow analytics team started a seed listing manually curated "premium market" designations for about 40 metro areas — a small, deliberate business decision, a textbook seed candidate. Over about a year, as the company expanded its premium tier, the same CSV grew to nearly 2,000 rows, with analysts adding new entries by hand almost every week as new markets launched, sourced from an internal planning spreadsheet that had effectively become the real source of truth.
The team eventually recognized this had quietly crossed the boundary from Part 03: a weekly-updated, near-2,000-row file being manually edited by several different people was no longer "small and mostly static," it was standing in for a real ingestion process. They moved the underlying planning data into an actual internal tool with its own database table, added a source() pointing at a proper extract of it, and reduced the seed back down to a genuinely small, rarely-changing table of only the handful of top-level tier definitions that really were fixed business decisions.
Thumbtack's marketing analytics team needed a way to group dozens of specific UTM campaign values into a small number of consistent categories — paid_social, affiliate, organic_search, referral — for reporting, a categorization decided entirely by the marketing team itself with no natural home in any operational system. They built exactly the seed pattern from Part 06: a small CSV mapping raw UTM values to categories, with schema.yml tests asserting the category column only ever contains the agreed-upon fixed list of values.
When a new ad platform integration started generating a UTM value nobody had added to the seed yet, the accepted_values test on the category seed's downstream usage — not the seed table itself, but a mart model joining against it — flagged the gap the same day the new campaign went live, rather than that traffic silently landing in an "uncategorized" bucket for weeks before anyone in marketing happened to notice a discrepancy in a channel performance report.
5 Interview Questions — With Complete Answers
These five questions cover what an interviewer is actually checking when they ask about seeds: not just "can you name the command," but whether you understand the scope boundary that keeps seeds useful, and can reason concretely about a type-inference bug that has bitten real projects.
Five Mistakes Engineers Make Working With Seeds
dbt Seed Errors — And Exactly Why They Happen
🎯 Key Takeaways
- ✓A seed is a .csv file in seeds/ that dbt seed loads into the warehouse as a real table — the one dbt object type that starts as a flat file instead of a SELECT statement, but otherwise participates in the DAG exactly like a model.
- ✓Seeds are for small, mostly-static, human-curated reference data with no natural operational source system — a country-region mapping, a campaign category list. Large or frequently-changing data belongs behind a real ingestion pipeline and a source(), not a seed.
- ✓dbt infers seed column types from CSV values, which reliably fails for columns like ZIP codes with leading zeros — seed-column-types forces the correct type, and dbt seed --full-refresh is required afterward to actually rebuild the table's schema.
- ✓dbt seed replaces a seed table's row contents from the current CSV on most adapters; dbt seed --full-refresh additionally forces a schema rebuild, which matters specifically after changing a column's configured type.
- ✓A seed gets its own schema.yml with descriptions and tests exactly like a model, and is referenced from any model with the same ref() function — it is a first-class dbt node, not a lesser one.
- ✓The scope boundary between "seed" and "should be a real ingestion pipeline" is worth periodically re-checking — a seed that starts genuinely small and static can quietly grow past that boundary over time without anyone deciding it should.
Discussion
0Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.