Models, Sources, and Seeds in dbt: A Complete Tutorial for Transforming Data with Reproducible SQL
dbt Data Engineering

Models, Sources, and Seeds in dbt: A Complete Tutorial for Transforming Data with Reproducible SQL

Valentín Chab
Valentín Chab | | 12 min read

Welcome to the second part of this series on DBT. In the first installment, we did a general introduction to this data transformation framework, covered its core concepts, the components of a project, and ran a set of simple commands to initialize our first data transformation process. If you missed it, you can read it here.

We’ll dive deeper into several of the concepts already mentioned, focusing on understanding them thoroughly and taking full advantage of the tool’s capabilities. We’ll cover models, data sources, and seeds, and keep professionalizing our test project.

Models

This is the heart of our project. In DBT, a model is simply a SQL file containing a SELECT statement, nothing more, nothing less. While that sounds pretty straightforward, behind the scenes models function as abstractions that let us create modular, maintainable, version-controlled, and testable transformation logic for our data warehouse. Let’s take a closer look at what they are and how they work.

What Is a Model, Really?

A model is a .sql file located in the models/ directory of our DBT project. Each model typically represents a transformation step, such as data cleaning, setting a column’s type, aggregating or summarizing data, joining data sources, or countless other options. Let’s look at a simple model example:

Cargando gist...

In this case, we’re selecting active customers and referencing the raw_customers model using the ref() function (which we’ll explore in more depth shortly) to indicate where the data comes from. Here, we can reference raw data sources like data warehouses or we can point to another model’s output. That’s how transformations get chained together. My reference to raw_customers could just as easily have been to a hypothetical int_select_customers, a previous .sql file that filters the customers we want to work with.

When we use the dbt run command to execute our models, they get compiled into SQL and run against our data warehouse, resulting in a materialization usually in the form of a view or table (DBT supports other materialization types but they’re much less common), depending on how we configure it. In fact, once executed we can inspect the compiled code in the target/compiled directory and see how abstractions like ref() translate into raw SQL.

Now, let’s analyze the parts of a model.

SQL Logic

As mentioned earlier, the SELECT statement is the core of the model. It can be as simple or as complex as needed, with filters, joins, subqueries, CTEs… We have the full SQL arsenal at our disposal.

The ref() Function

This function is used to reference another model and serves several roles:

When in our example we use FROM {{ ref(‘raw_customers’) }}, DBT understands that it must first build ‘raw_customers’ before executing our current model.

Model Configurations and Settings

Cargando gist...

Models in DBT can have specific configurations that determine how and where results are materialized, how resulting tables are named, or how they’re grouped for automated tasks, among other things. These configurations are defined at the top of the model’s .sql file using the {{ config(…) }} block or in the dbt_project.yml file.

Let’s look at the most common parameters and their uses:

materialized

This is probably the most important setting, as it defines how the model’s result is materialized in the data warehouse. It can take one of the following values:

  • view: the model’s result is created as a view and is the default option. It doesn’t take up disk space and always reflects up-to-date data, though it may imply higher compute costs when queried.
  • table: the model materializes as a table. This means data is persisted at dbt run time. It’s ideal for heavy transformations or when query time optimization is needed.
  • incremental: this option allows updating only part of the data each time the model runs, rather than recreating the entire table. It’s useful for handling large data volumes in production pipelines.
  • ephemeral: not materialized in the warehouse. The model becomes a CTE (Common Table Expression) within another model that consumes it. It’s the best option for intermediate steps that you don’t want to persist.

schema

Allows overriding the default schema configured in the project. Useful when you want to segment your models by environment (e.g., dev, staging, prod) or by specific processes.

alias

Controls the final name under which the table or view is created in the warehouse. By default, DBT uses the .sql filename as the generated table name, but with alias you can change it while maintaining file naming conventions in your transformation process.

tags

Allow labeling models for logical grouping. These tags can then be used to run only a specific part of the project with commands like dbt run –select tag:incrementals, or for analysis and documentation purposes.

Sources

In every DBT model, the first step is consuming data that already exists in our system: transactional databases, third-party systems, APIs, or any source we deem necessary or appropriate. Within the DBT framework, these external sources (i.e., tables not generated by DBT but already present in the data warehouse) are configured as sources.

This lets DBT know that those data are part of our transformation logic and enables us to use features like automatic tests, centralized documentation, and clear visualization in the dependency DAG.

Defining Sources

Data sources are defined in .yml files within the models/ directory, typically in files like src_*.yml, though no specific naming convention is required; any name works. There we use a YAML structure to register our external data.

Cargando gist...

In this case, we have the following components:

  • raw_db is a logical identifier for the source database.
  • schema defines the warehouse schema where the actual tables live.
  • tables is the list of concrete tables we want to use as sources.

Implementing Sources

Now that we have the data sources defined in our YAML and ready to use… how do we actually implement them in our model? That’s where the source() function comes in. Let’s look at a small example:

Cargando gist...

In these two simple lines of code, we’re telling DBT: “I want to consume the customers table in the raw schema that’s part of the rawdb source.” In other words, just like with the _ref() function, DBT abstracts much of the behind-the-scenes work through functions. But unlike ref(), source() is used to reference external data.

Using source() also provides several benefits over directly using the table name in the model:

  • Traceability and documentation: DBT can include this data in the automatically generated documentation using the dbt docs generate command (which we’ll use later to create our project’s documentation).
  • Tests: We can apply automatic tests on external tables (like verifying they don’t have null values, that they have a valid primary key, among many other options).
  • Change management: If the schema or table name changes, we only need to modify it in the .yml file and not in every model.
  • DAG visualization: Sources appear as nodes in the dependency graph, making it much easier to understand the complete flow.

Additional Configurations

The last thing we’ll cover about sources today are two simple but useful additional configurations for defining our sources. For example, we can document both sources and tables:

Cargando gist...

And we can declare tests we want to implement on our source tables:

Cargando gist...

This ensures that customer_id is unique and not null. Defining solid tests is an excellent practice for making our data transformation pipelines robust.

Seeds

The last component we’ll cover today is seeds. In DBT, seeds are CSV files that live within the project and get loaded directly into the data warehouse as tables. They function as small static databases: they can represent reference catalogs, code lists, business rules, mappings, or even test data. In other words, they’re especially useful when we have a dataset that won’t change or will only change in very controlled ways, like a company’s list of operating countries or a small list of suppliers.

This capability is especially useful when we want to incorporate simple or team-controlled datasets without depending on external integrations or complex ingestion processes. We copy the CSV into the /seeds folder in our directory, run the dbt seed command, and we’re ready to consume that data.

The key difference between seeds and sources when using the data is that seeds are referenced with the ref() function we’ve already seen, just like models. We’ll see a seed in action in the demo.

Demo Time!

Now that we have these concepts well baked, let’s keep moving forward with the demo we started in the last post. First things first, to keep the project organized, let’s delete the my_project/models/example folder that DBT auto-generated when initializing the project, and then we’ll create a dummy database so we have some data to process and see results.

To spin up the Docker container again (unless you’ve patiently waited all these weeks with your computer on and the container running), let’s run these commands in our terminal to activate the virtual environment we created last time and start the container:

Terminal with docker and psql commands starting the dbt_postgres container and opening a Postgres session

Starting the dbt_postgres container and connecting with psql inside the dbt virtual environment

Now, let’s create two dummy tables inside our container so we can build some transformations in DBT. Copy and paste the following code in the terminal.

Cargando gist...

Now let’s add dummy data. Here’s the raw_customers data:

Cargando gist...

And here’s the raw_orders data:

Cargando gist...

Then you can run this query to verify the contents of the tables we created:

Cargando gist...

CREATE TABLE commands executed in psql to define raw_customers and raw_orders inside the Postgres container used by dbt

INSERT INTO commands in Postgres adding customer and order records to raw_customers and raw_orders tables

Now let’s define and configure our new sources. Inside the models folder (my_project/models/), we’ll create a YAML file called src_raw_data.yml. In it, we’ll paste the following content:

Cargando gist...

We now have both tables defined as sources in our project, and we’ve also configured a couple of tests (uniqueness and non-nullability of the IDs). Next step, let’s update our dbt_project.yml file to include the new model information. Put this code in:

Cargando gist...

Here we’ve set the configurations for our models in the project’s general file and defined our materialization strategy as “view.” Now we can finally create our intermediate model! Inside the /models folder, let’s create a new .sql file and paste the following SQL code.

Cargando gist...

This is a model just like the one we saw at the beginning of the post. What it does is take our two sources, assign them an alias, and join them using our customer_id. Now, to run it, execute the following command:

dbt run –select int_summarize_data

If you did everything correctly, you should see something like this:

Terminal showing dbt run –select int_summarize_data: Postgres adapter registered, view created, and PASS reported with no errors

If you want to see the compiled code, you can run this:

dbt compile –select int_summarize_data

And if we want to see the result of what was materialized as a view, we can do it with this line:

psql -U postgres -h localhost -p 5432 -d postgres -c “SELECT * FROM postgres.int_summarize_data LIMIT 10;”

This will ask for the password we defined last time in the profiles.yml file. The result should be:

Terminal output from psql with the first 10 records of the int_summarize_data view, including customer, date, and amount fields

Done! We’ve now run our first custom DBT model and joined data from both tables using customer_id as the common key. We can now see the first name, last name, email, creation date, order number, order date, and order amount for each of our customers.

To close out today’s demo, we’ll create a seed and use it to classify our sales by size. You can find the CSV with the data here and download it directly (delete the “- Hoja 1” from the name!). This file contains order classification categories for our customers, and since it’s static, brief data that won’t change frequently, it’s appropriate to treat it as a seed.

If you look, beneath the /models folder we have one called seeds. We’ll place our freshly downloaded file there and update our dbt_project.yml with the seed configuration.

Cargando gist...

With all that done, we can run the seed to load it.

dbt seed

The result should look something like this:

dbt seed log inserting 5 rows from the order_size seed into Postgres, with warnings about spaces in the name

After that, we’ll create a new intermediate. We’ll call it int_categorize_orders and it’ll be a .sql file. In it, we’ll put the following query.

Cargando gist...

Now, if we run this model, we’ll unify our transformations.

dbt run –select int_categorize_orders

And if we execute a query on the view, we’ll see the results.

psql -U postgres -h localhost -p 5432 -d postgres -c “SELECT * FROM postgres.int_categorize_orders LIMIT 10;”

Query result showing customers with the new category field (small, medium, large) calculated via seed in dbt

Wrapping Up…

It’s been another heavy learning session with lots of new concepts and tools in our arsenal. Today we understood in depth what models are, how they’re configured, and how they’re executed; we learned to define and use sources for our data transformations; we got familiar with the seed concept; we understood how to configure project settings; and in the demo we even worked with our first chained transformations. Pretty solid!

If you’re hungry for more, there’s still plenty to come. We’ll do another post covering how to create marts, document our processes, and run tests to ensure data integrity.

Valentín Chab

Valentín Chab

Data Scientist @ deployr

Share

Got a real technical problem?

We don't sell generic solutions. Let's talk about what you need to solve.

Let's talk