Build a Metrics Layer with dbt and MetricFlow
Build a metrics-as-code layer using dbt and MetricFlow in Docker, with portable KPI definitions.
Introduction
30 Second Summary
When your team tracks vehicle fleet health, a single KPI like "critical warning rate" might be defined in a Grafana query, a Python notebook, and a weekly report spreadsheet. When the business decides to redefine "critical," someone has to hunt down every copy and update it manually.
In this project, you will build a metrics-as-code layer using dbt and MetricFlow that gives your vehicle telemetry KPIs a single, version-controlled source of truth. Change a YAML file, and every downstream consumer gets the updated definition automatically.
What You'll Build
A configurable metrics platform layer that sits between your PostgreSQL vehicle telemetry database and your downstream consumers, with all KPI definitions stored as version-controlled YAML. The entire stack runs in Docker containers, so you can develop locally and deploy to any VM without changing a single configuration file.
By the end of this project, you'll have:
- A Docker Compose stack with PostgreSQL and dbt that runs identically on your laptop or a cloud VM.
- Configurable KPI metrics defined in YAML that any team member can propose changes to via Git pull requests.
- A working MetricFlow CLI workflow where you query and validate metric definitions locally, proving that a YAML change instantly propagates to computed results.
- Secret Mission: Add a derived ratio metric and set up a GitHub Actions CI workflow that validates metric definitions on every pull request.
Are there any prerequisites?
You need Docker Desktop installed and running, Git installed, and a code editor of your choice. No Python installation is required on your host machine.
Set Up dbt Core with MetricFlow and Connect to PostgreSQL
Set Up the Docker Environment
Before you can define metrics as code, you need a development environment with dbt, MetricFlow, and a PostgreSQL database containing vehicle telemetry data.
In this step, you will build a Docker Compose stack that packages everything together. Because the entire environment lives in containers, you can run it on your laptop today and move it to a Google VM tomorrow without changing a single configuration file.
In this step, get ready to:
- Create a Dockerfile and Docker Compose configuration for dbt and PostgreSQL.
- Seed the PostgreSQL container with sample vehicle telemetry data.
- Verify the dbt connection to your containerized database.
Create the project folder and Docker configuration files
Docker Compose lets you define multiple services (a database, a tooling container) in a single YAML file and start them together with one command.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir vehicle-metrics
cd vehicle-metrics
- Confirm the folder exists by running:
ls
You should see the empty vehicle-metrics folder listed in the output.
- In your code editor, create a new file called Dockerfile inside vehicle-metrics and paste the following:
FROM python:3.11-slim
# Install system dependencies for psycopg2
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
# Install dbt-metricflow with the PostgreSQL adapter
RUN pip install --no-cache-dir "dbt-metricflow[dbt-postgres]"
# Set the working directory
WORKDIR /usr/src/dbt
# Keep the container running for interactive use
CMD ["tail", "-f", "/dev/null"]
What does this Dockerfile do?
- FROM python:3.11-slim starts from a lightweight Python 3.11 image.
- The apt-get line installs system libraries that the PostgreSQL adapter needs to compile.
- pip install "dbt-metricflow[dbt-postgres]" installs dbt-core, MetricFlow, and the Postgres adapter in compatible versions.
- The CMD keeps the container alive so you can shell into it and run commands interactively.
- Save Dockerfile.
- Create a new file called docker-compose.yml in the same vehicle-metrics folder and paste the following:
services:
db:
image: postgres:16
environment:
POSTGRES_USER: dbt_user
POSTGRES_PASSWORD: dbt_pass
POSTGRES_DB: vehicle_telemetry
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
- ./init-db:/docker-entrypoint-initdb.d
healthcheck:
test: ["CMD-SHELL", "pg_isready -U dbt_user -d vehicle_telemetry"]
interval: 5s
timeout: 3s
retries: 5
dbt:
build: .
depends_on:
db:
condition: service_healthy
volumes:
- ./dbt_project:/usr/src/dbt
- ./profiles:/root/.dbt
environment:
DBT_PROFILES_DIR: /root/.dbt
volumes:
pgdata:
What does this configuration do?
- db runs PostgreSQL 16 with a database called vehicle_telemetry. The init-db volume mount auto-runs seed scripts on first start.
- dbt builds from your Dockerfile and waits for the database to be healthy before starting.
- The volumes mounts map your local dbt_project/ and profiles/ folders into the container. You edit files locally, and dbt sees them instantly inside the container.
- host: db in profiles uses Docker's internal networking. The same compose file works on any machine with Docker installed.
- Save docker-compose.yml.
Why Docker instead of a local Python install?
A local Python virtual environment ties you to one machine. If you want to move this project to a Google VM, you would need to reinstall Python, recreate the venv, and re-configure paths.
With Docker Compose, you copy the project folder to any machine, run docker compose up, and everything works. The database, the dbt tooling, and the network configuration travel together.
Seed the database with vehicle telemetry data
PostgreSQL's official Docker image runs any SQL scripts placed in /docker-entrypoint-initdb.d automatically on first container start. You will create a seed script that builds the raw tables and inserts sample data.
- Create an init-db folder inside vehicle-metrics by running:
mkdir init-db
- Create a new file at init-db/01_seed.sql and paste the following:
-- Create raw vehicle telemetry tables
CREATE TABLE vehicle_warnings (
id SERIAL PRIMARY KEY,
vehicle_id VARCHAR(20) NOT NULL,
warning_level VARCHAR(20) NOT NULL,
event_time TEXT NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE vehicle_gps (
id SERIAL PRIMARY KEY,
vehicle_id VARCHAR(20) NOT NULL,
region VARCHAR(50) NOT NULL,
activity_date TEXT NOT NULL,
distance_km NUMERIC(10,2) NOT NULL,
recorded_at TIMESTAMP DEFAULT NOW()
);
-- Insert sample vehicle warnings
INSERT INTO vehicle_warnings (vehicle_id, warning_level, event_time) VALUES
('VH-001', 'critical', '2025-06-01 08:15:00'),
('VH-001', 'minor', '2025-06-01 09:30:00'),
('VH-002', 'critical', '2025-06-01 10:45:00'),
('VH-003', 'major', '2025-06-02 07:00:00'),
('VH-002', 'critical', '2025-06-02 11:20:00'),
('VH-001', 'critical', '2025-06-03 14:00:00'),
('VH-003', 'minor', '2025-06-03 16:30:00'),
('VH-004', 'critical', '2025-06-04 08:00:00'),
('VH-004', 'major', '2025-06-04 12:15:00'),
('VH-002', 'minor', '2025-06-05 09:00:00');
-- Insert sample GPS data
INSERT INTO vehicle_gps (vehicle_id, region, activity_date, distance_km) VALUES
('VH-001', 'north', '2025-06-01', 142.50),
('VH-001', 'north', '2025-06-02', 98.30),
('VH-002', 'south', '2025-06-01', 210.00),
('VH-002', 'south', '2025-06-02', 175.60),
('VH-003', 'east', '2025-06-01', 88.90),
('VH-003', 'east', '2025-06-02', 120.40),
('VH-004', 'west', '2025-06-01', 305.20),
('VH-004', 'west', '2025-06-02', 280.10),
('VH-001', 'north', '2025-06-03', 155.00),
('VH-003', 'east', '2025-06-03', 92.70);
What does this seed script do?
It creates two raw tables (vehicle_warnings and vehicle_gps) and inserts 10 rows into each. These represent GPS distance logs and warning events from a fleet of four vehicles.
The 01_ prefix ensures this script runs first if you add more seed files later. PostgreSQL executes them in alphabetical order.
- Save 01_seed.sql.
Create the dbt profiles configuration and start the containers
The dbt profiles file tells dbt how to connect to your database. Because both containers share a Docker network, the hostname is simply db (the service name from your compose file).
- Create a profiles folder inside vehicle-metrics by running:
mkdir profiles
- Create a new file at profiles/profiles.yml and paste the following:
vehicle_metrics:
target: dev
outputs:
dev:
type: postgres
host: db
port: 5432
user: dbt_user
pass: dbt_pass
dbname: vehicle_telemetry
schema: vehicle_metrics_staging
threads: 4
What does this configuration do?
- host: db uses Docker's internal DNS to reach the PostgreSQL container by its service name.
- The credentials match the POSTGRES_USER and POSTGRES_PASSWORD you set in docker-compose.yml.
- schema: vehicle_metrics_staging keeps dbt's output models separate from your raw seed tables in the public schema.
- Save profiles.yml.
- Create an empty dbt_project folder (dbt will use this as its working directory) by running:
mkdir dbt_project
- Build and start both containers by running:
docker compose up -d --build
Docker will download the Python and PostgreSQL images, build your dbt container, run the seed script, and start both services. This may take 2-3 minutes on the first run.
Seeing a port conflict on 5432?
If you already have PostgreSQL running locally, it may be using port 5432. Either stop your local PostgreSQL, or change the host port in docker-compose.yml from "5432:5432" to "5433:5432". The dbt container connects over the internal network and is not affected by the host port mapping.
Help me fix a Docker Compose port conflict.
- Verify both containers are running by running:
docker compose ps
You should see two services listed (db and dbt) both with a status of Up.
- Shell into the dbt container by running:
docker compose exec dbt bash
Your terminal prompt changes to show you are now inside the container. All dbt and mf commands from this point forward run inside this shell.
- Initialize the dbt project by running:
dbt init vehicle_metrics
What will dbt init ask me?
The command prompts you to select a database adapter. Choose postgres (it may appear as option 1). You can skip the remaining prompts by pressing Enter for each one. Your connection details are already configured in profiles.yml.
After the init completes, dbt creates a vehicle_metrics/ folder inside your working directory with the standard project structure.
- Move into the project directory by running:
cd vehicle_metrics
- Test the database connection by running:
dbt debug
You should see a series of checks ending with "All checks passed" confirming dbt can reach the PostgreSQL container.
Seeing connection errors?
- Make sure both containers are running (docker compose ps from a separate terminal window outside the container).
- Check that the host value in profiles/profiles.yml is db (not localhost).
- Confirm the credentials in profiles.yml match the POSTGRES_USER and POSTGRES_PASSWORD in docker-compose.yml.
Help me fix my dbt debug connection errors inside Docker.
Your Docker environment is connected and ready. Next up, you will create base SQL models that give dbt a clean interface to your raw vehicle telemetry tables.
Create Base Models for Vehicle Telemetry Data
Your dbt project is connected to the PostgreSQL container and ready to transform data. The next goal is to create a clean interface between your raw database tables and the metrics you will define later.
Raw tables often have inconsistent column names, ambiguous types, and no documentation. Staging models solve this by selecting from raw tables, renaming columns to consistent conventions, and casting types explicitly. In this step, you will create staging models for your vehicle warnings and GPS data.
In this step, get ready to:
- Create staging SQL models that rename and cast columns from your raw telemetry tables.
- Define a source YAML file that documents your raw PostgreSQL tables for dbt.
- Run and verify the models materialize as views in your database.
Create staging SQL models for warnings and GPS data
Staging models are the first transformation layer in a dbt project. They select from raw source tables and apply renaming, type casting, and filtering so that downstream models always work with a consistent, predictable schema.
- Inside your dbt container shell, create a staging folder inside models/ by running this command from the vehicle_metrics project folder:
mkdir models/staging
- In your code editor, create a new file at models/staging/stg_vehicle_warnings.sql and paste the following code:
with source as (
select * from {{ source('vehicle_telemetry', 'vehicle_warnings') }}
)
select
id as warning_id,
vehicle_id,
warning_level,
event_time::timestamp as event_time,
created_at
from source
What does this code do?
The with source block uses dbt's {{ source() }} function to reference your raw table. This creates a dependency that dbt tracks automatically.
The select renames id to warning_id for clarity and casts event_time to a timestamp type explicitly. Downstream models can rely on these types without guessing.
- Save stg_vehicle_warnings.sql.
- Now create a second file at models/staging/stg_vehicle_gps.sql and paste the following code:
with source as (
select * from {{ source('vehicle_telemetry', 'vehicle_gps') }}
)
select
id as gps_id,
vehicle_id,
region,
activity_date::date as activity_date,
distance_km as total_distance,
recorded_at
from source
What does this code do?
This model follows the same pattern. It renames id to gps_id, casts activity_date to a date type, and renames distance_km to total_distance to match the naming convention your semantic models will expect later.
- Save stg_vehicle_gps.sql.
Not sure where to create the files?
Both files go inside models/staging/ within the vehicle_metrics project. On your local machine, this maps to vehicle-metrics/dbt_project/vehicle_metrics/models/staging/ thanks to the Docker volume mount. You can create and edit files either locally in your code editor or inside the container shell.
Help me find the right directory for my staging models.
Define your raw tables in a source YAML file
Your staging models reference {{ source('vehicle_telemetry', ...) }}, but dbt does not know what that source is yet. A source definition file tells dbt where to find the raw tables in your database.
- Create a new file at models/staging/_sources.yml and paste the following:
sources:
- name: vehicle_telemetry
database: vehicle_telemetry
schema: public
tables:
- name: vehicle_warnings
- name: vehicle_gps
What does this file do?
The name: vehicle_telemetry field gives the source a logical name that your staging models reference via {{ source('vehicle_telemetry', 'table_name') }}.
The database and schema fields tell dbt exactly where to find these tables in your Docker PostgreSQL container. The tables list registers each table so dbt can validate references and generate documentation automatically.
- Save _sources.yml.
Run dbt and verify the staging models
With your source definitions and staging models in place, dbt can compile the SQL and execute it against your database. The dbt run command materializes each model as a view (the default) in your target schema.
- Materialize your staging models by running this command from the vehicle_metrics project folder:
dbt run
- You should see output confirming both models completed successfully. Look for lines showing stg_vehicle_warnings and stg_vehicle_gps created as views.
Seeing a compilation error?
If dbt reports a source not found error, check that your _sources.yml file is saved inside models/staging/ and that the name field matches exactly what your SQL files reference (vehicle_telemetry).
If the error mentions a missing table, verify that your database and schema values in _sources.yml match where your raw tables actually live in PostgreSQL.
Help me debug my dbt run error.
- Preview the output of your warnings model by running:
dbt show --select stg_vehicle_warnings
- You should see a table of rows from your vehicle warnings data with the renamed columns: warning_id, vehicle_id, warning_level, event_time, and created_at. The first 5 rows display by default.
What is dbt show?
The dbt show command compiles and runs the model's SQL, then displays the results directly in your terminal without materializing anything new. It is a quick way to verify your transformations produce the expected output.
✔️ Awesome, I've got everything!
Great. Double check you have saved all three files before moving on.
ⓧ I'd like to double check the full code
Here is the complete content for each file in your models/staging/ directory.
sources:
- name: vehicle_telemetry
database: vehicle_telemetry
schema: public
tables:
- name: vehicle_warnings
- name: vehicle_gps
with source as (
select * from {{ source('vehicle_telemetry', 'vehicle_warnings') }}
)
select
id as warning_id,
vehicle_id,
warning_level,
event_time::timestamp as event_time,
created_at
from source
with source as (
select * from {{ source('vehicle_telemetry', 'vehicle_gps') }}
)
select
id as gps_id,
vehicle_id,
region,
activity_date::date as activity_date,
distance_km as total_distance,
recorded_at
from source
Your staging layer is in place. Next up, you will define semantic models on top of these views. Semantic models describe the meaning of your data (entities, dimensions, measures) so that MetricFlow can generate metric queries automatically.
Define Semantic Models for Vehicle Telemetry
Your staging models give you a clean interface to the raw PostgreSQL tables. The next layer is telling MetricFlow what those columns actually mean in business terms.
A semantic model maps each staging model to a structured definition of entities (join keys), dimensions (ways to slice data), and measures (aggregations). Once defined, MetricFlow can compile any combination of metrics, dimensions, and filters into efficient SQL without you writing it by hand.
In this step, get ready to:
- Define a semantic model for vehicle warnings data with entities, dimensions, and measures.
- Define a semantic model for GPS/fleet activity data.
- Validate both semantic model definitions with MetricFlow.
Create a semantic model for vehicle warnings
Each semantic model lives in a YAML file and references one of your staging models. You declare entities (the join keys MetricFlow uses to connect data), dimensions (the columns you can group or filter by), and measures (the raw aggregations that feed into metrics).
- Create a new file called semantic_models.yml inside vehicle_metrics/models/staging/ and paste the following code:
semantic_models:
# Semantic model for vehicle warning events
- name: vehicle_warnings
model: ref('stg_vehicle_warnings')
entities:
- name: vehicle_id
type: primary
expr: vehicle_id
dimensions:
- name: warning_level
type: categorical
expr: warning_level
- name: event_time
type: time
type_params:
time_granularity: day
expr: event_time
measures:
# Total count of all warnings regardless of severity
- name: warning_count
agg: count
expr: warning_id
# Count only critical warnings (level 4+)
- name: critical_warning_count
agg: count
expr: warning_id
agg_params:
where: warning_level >= 4
What does this YAML do?
This semantic model tells MetricFlow how to interpret the stg_vehicle_warnings staging model.
- vehicle_id is the primary entity. MetricFlow uses it as the join key when combining this data with other semantic models.
- warning_level is a categorical dimension. You can group or filter metrics by severity level.
- event_time is a time dimension with day granularity. This lets you query metrics across time periods.
- critical_warning_count uses agg_params.where to filter the count to only rows where warning_level >= 4. This is the configurable threshold you will change later to demonstrate definition changes.
- Save semantic_models.yml.
You will not see a visible change yet. The next chunk adds the GPS semantic model, then you will validate both together.
Add the GPS semantic model
The second semantic model describes your fleet activity data. It uses count_distinct to count unique active vehicles and sum for total distance traveled.
- In semantic_models.yml, add the following YAML below the last line of the vehicle_warnings semantic model (after the where: warning_level >= 4 line). Make sure the indentation aligns with the first - name: vehicle_warnings entry:
# Semantic model for GPS/fleet activity data
- name: vehicle_gps
model: ref('stg_vehicle_gps')
entities:
- name: vehicle_id
type: primary
expr: vehicle_id
dimensions:
- name: region
type: categorical
expr: region
- name: activity_date
type: time
type_params:
time_granularity: day
expr: activity_date
measures:
# Count of unique vehicles that reported GPS data
- name: active_vehicles
agg: count_distinct
expr: vehicle_id
# Sum of kilometers traveled across the fleet
- name: total_distance
agg: sum
expr: total_distance
What does this semantic model define?
- vehicle_id is again the primary entity. Both semantic models share this key, which means MetricFlow can join them later for cross-model metrics.
- region is a categorical dimension. You will be able to slice fleet utilization by geographic area.
- active_vehicles uses count_distinct on vehicle_id to count unique vehicles that reported activity in a given period.
- total_distance sums the total_distance column from stg_vehicle_gps to get fleet-wide kilometers.
- Save semantic_models.yml.
Seeing indentation issues?
YAML is whitespace-sensitive. The second semantic model entry (- name: vehicle_gps) must be at the same indent level as the first entry (- name: vehicle_warnings). Both should be indented two spaces under semantic_models:.
If you see errors about unexpected keys or mapping issues, check that all nested keys (entities, dimensions, measures) are indented consistently with four spaces under their parent model entry.
help me fix YAML indentation in my semantic_models.yml file
✔️ Awesome, I've got everything!
Great. Double check you have saved semantic_models.yml with both semantic model definitions before moving on.
ⓧ I'd like to double check the full code
Here is the complete semantic_models.yml file. Compare yours against it:
semantic_models:
- name: vehicle_warnings
model: ref('stg_vehicle_warnings')
entities:
- name: vehicle_id
type: primary
expr: vehicle_id
dimensions:
- name: warning_level
type: categorical
expr: warning_level
- name: event_time
type: time
type_params:
time_granularity: day
expr: event_time
measures:
- name: warning_count
agg: count
expr: warning_id
- name: critical_warning_count
agg: count
expr: warning_id
agg_params:
where: warning_level >= 4
- name: vehicle_gps
model: ref('stg_vehicle_gps')
entities:
- name: vehicle_id
type: primary
expr: vehicle_id
dimensions:
- name: region
type: categorical
expr: region
- name: activity_date
type: time
type_params:
time_granularity: day
expr: activity_date
measures:
- name: active_vehicles
agg: count_distinct
expr: vehicle_id
- name: total_distance
agg: sum
expr: total_distance
Validate the semantic model definitions
MetricFlow includes a validation command that parses your YAML and checks for semantic errors such as missing entities, type mismatches, or invalid aggregation references.
- Validate your semantic models by running this command from the vehicle_metrics project directory:
mf validate-configs
- You should see output indicating that all checks passed with no errors. Look for a success message confirming that your semantic model definitions are valid.
What does this command do?
mf validate-configs runs parsing and semantic validations against your YAML definitions. It checks that referenced models exist, entity types are valid, dimensions use supported types, and measure aggregations are recognized.
Running this after every definition change catches errors before you attempt to query. It is the same validation that your CI pipeline will run in the Secret Mission.
🙋♀️ Validation errors?
If you see a parsing error, check that your YAML has consistent indentation (two spaces per level) and that model: ref('stg_vehicle_warnings') matches the exact model name in your models/staging/ directory.
If you see a semantic validation error about a missing model, make sure you ran dbt run successfully in the previous step. MetricFlow needs the compiled models to validate against.
help me debug mf validate-configs errors in my semantic model definitions
Your semantic models are validated and ready. Next up, you will define configurable KPI metrics that reference these measures and can be queried by time and dimension.
Define Configurable KPI Metrics
Your semantic models define the raw building blocks: measures, dimensions, and entities. Metrics are the business-facing KPIs that sit on top of those building blocks.
In this step, you'll define two configurable metrics as YAML that reference the measures from your semantic models. Because these metrics are plain YAML files tracked in Git, any team member can propose a KPI change via a pull request.
In this step, get ready to:
- Define a critical warning rate metric from your warnings data.
- Define a fleet utilization metric from your GPS data.
- Query both metrics locally using the MetricFlow CLI.
Define the critical warning rate metric
A simple metric in MetricFlow acts as a proxy to a measure you already defined in your semantic model. It gives that measure a label for downstream tools and a place to attach filters or configuration later.
- Create a new file called metrics.yml inside vehicle_metrics/models/staging/ by running:
touch vehicle_metrics/models/staging/metrics.yml
- Open metrics.yml in your code editor and paste the following:
# Configurable KPI metrics for vehicle telemetry
metrics:
- name: critical_warning_rate
label: Critical Warning Rate
type: simple
type_params:
measure: critical_warning_count
What does this YAML do?
This defines a simple metric called critical_warning_rate that points to the critical_warning_count measure you defined in your semantic model.
The label field controls how the metric appears in downstream tools like Grafana dashboards. The type_params.measure shorthand tells MetricFlow which measure to proxy. Because the "critical" threshold lives in the staging SQL (warning_level >= 4), changing that filter is all it takes to redefine what counts as critical.
- Save metrics.yml.
- You won't see output yet. The next substep adds your second metric, then you'll validate both together.
Add the fleet utilization metric
Fleet utilization tells you how many distinct vehicles are active in a given time period. This metric references the active_vehicles measure from your GPS semantic model.
- In metrics.yml, add a second metric entry below the critical_warning_rate definition. Your full file should now look like this:
# Configurable KPI metrics for vehicle telemetry
metrics:
- name: critical_warning_rate
label: Critical Warning Rate
type: simple
type_params:
measure: critical_warning_count
- name: fleet_utilization
label: Fleet Utilization
type: simple
type_params:
measure: active_vehicles
What does fleet_utilization do?
This metric proxies the active_vehicles measure, which uses a count_distinct aggregation on vehicle_id. When you query it grouped by region, you get a per-region breakdown of how many unique vehicles reported GPS data.
Both metrics are now just YAML. Changing a label, swapping an underlying measure, or adding a filter is a one-line edit that any team member can propose through a pull request.
- Save metrics.yml.
- Validate both metric definitions by running:
mf validate-configs
- You should see a success message with no errors. This confirms both metrics are syntactically correct and reference valid measures from your semantic models.
Seeing a validation error?
- Check that the measure value in each metric exactly matches the measure name in semantic_models.yml. For example, critical_warning_count must match character-for-character.
- Make sure you are running the command from inside the vehicle_metrics project directory.
- Ensure metrics.yml starts with the top-level metrics: key, not semantic_models:.
Why is mf validate-configs failing on my metrics.yml file?
✔️ Awesome, I've got everything!
Great. Double check you've saved metrics.yml before moving on.
ⓧ I'd like to double check the full code
metrics:
- name: critical_warning_rate
label: Critical Warning Rate
type: simple
type_params:
measure: critical_warning_count
- name: fleet_utilization
label: Fleet Utilization
type: simple
type_params:
measure: active_vehicles
Verify and query your metrics
With both metrics defined and validated, you can now list them and run real queries against your PostgreSQL data.
- List all available metrics by running:
mf list metrics
- You should see both critical_warning_rate and fleet_utilization in the output.
Don't see both metrics listed?
- Make sure you saved metrics.yml after adding both entries.
- Run mf validate-configs again. If it reports errors, fix them before retrying mf list metrics.
Why doesn't mf list metrics show my newly defined metrics?
- Query the critical warning rate grouped by day by running:
mf query --metrics critical_warning_rate --group-by metric_time__day
- You should see a table of results showing daily counts of critical warnings from your PostgreSQL data.
- Query fleet utilization grouped by week and region by running:
mf query --metrics fleet_utilization --group-by metric_time__week,region
- You should see a table showing the count of distinct active vehicles per week, broken down by region.
What are the double underscores in metric_time__day?
MetricFlow uses double underscores (called "dunders") to specify the time granularity for a time dimension. metric_time__day means "group by the metric's time dimension at a daily grain."
You can swap day for week, month, or year to change the aggregation window. This is all handled by MetricFlow at query time. No YAML changes needed.
🙋♀️ Query returning an error?
- Confirm your dbt run from the previous step succeeded. MetricFlow queries compile SQL against your materialized staging models.
- Check that region is listed as a dimension in your vehicle_gps semantic model in semantic_models.yml.
- Verify your PostgreSQL database is reachable by running dbt debug.
Why is my mf query returning a SQL error against PostgreSQL?
You now have two version-controlled KPI metrics that query real data from your PostgreSQL database. Next up, you'll see how changing a single definition propagates instantly to computed results.
Demonstrate Configurable Definition Changes
In the previous step, you defined configurable KPI metrics and queried them locally with MetricFlow. Now it is time to prove the real power of metrics-as-code: changing a definition and watching the results update instantly.
When a business stakeholder says you need to redefine what counts as a critical warning, you do not want to dig through dashboards and SQL views. With your metrics layer, you change one YAML value or one SQL filter, re-run, and every downstream consumer gets the updated number.
In this step, get ready to:
- Modify the critical warning threshold and see how metric results update instantly.
- Add a new dimension to the semantic model and query metrics with new grouping.
- Export a metric to a materialized table for downstream consumption.
Modify the critical warning threshold
Your current critical warning metric counts warnings where warning_level >= 4. Imagine your fleet safety team decides that level 3 warnings should also be classified as critical.
You will make this change in two places: the semantic model measure filter and the staging SQL itself.
- In your code editor, open semantic_models.yml inside vehicle_metrics/models/staging/.
- Find this line under the critical_warning_count measure:
where: warning_level >= 4
- Replace 4 with 3, so the line now looks like this:
where: warning_level >= 3
- Save semantic_models.yml.
- Now open stg_vehicle_warnings.sql in the same staging/ folder.
- Find the last line of the query, which currently ends with:
from source
- Add a WHERE clause directly below from source, so the bottom of the file now looks like this:
from source
where warning_level >= 3
Why filter in both places?
The staging SQL filter removes noise at the source level. Warnings below level 3 never enter your staging model at all.
The semantic model's agg_params.where defines what the metric counts as critical within the available data. Keeping both aligned means the metric definition matches the underlying data boundary.
- Save stg_vehicle_warnings.sql.
- Rebuild the staging model by running this command:
dbt run
- Re-query the critical warning rate metric by running:
mf query --metrics critical_warning_rate --group-by metric_time__day
You should see higher counts per day compared to your earlier results. The threshold change from level 4 to level 3 means more warnings now qualify as critical.
What just happened?
You changed a single number in one SQL file and one YAML file, ran dbt run, and the metric results updated across the board.
In a traditional setup, you would need to update every dashboard query individually. With metrics-as-code, the definition lives in one place and propagates everywhere.
🙋♀️ Seeing the same numbers as before?
Make sure you ran dbt run after saving your changes. MetricFlow queries the rebuilt staging view, so the view must be updated first.
Also confirm that the where: warning_level >= 3 line in semantic_models.yml is indented correctly under agg_params.
Help me troubleshoot why my metric results did not change after updating the threshold.
Add a new dimension to the semantic model
Adding a new way to slice your metrics should be as easy as adding a few lines of YAML. You will now add a vehicle_type dimension so you can group critical warnings by the type of vehicle that generated them.
- In stg_vehicle_warnings.sql, find the select list. Locate this line:
vehicle_id,
- Add vehicle_type, on a new line directly below vehicle_id,, so these lines now look like:
vehicle_id,
vehicle_type,
- Save stg_vehicle_warnings.sql.
- Now switch to semantic_models.yml. Find the dimensions list under the vehicle_warnings semantic model.
- Locate the warning_level dimension entry. Add the following new dimension directly below it (above the event_time dimension):
- name: vehicle_type
type: categorical
expr: vehicle_type
What does this dimension do?
This tells MetricFlow that vehicle_type is a categorical dimension available for grouping. Any metric built on this semantic model can now be sliced by vehicle type without changing the metric definition itself.
- Save semantic_models.yml.
- Validate that your updated definitions parse correctly by running:
mf validate-configs
- You should see a success message with no errors.
- Rebuild the staging model to pick up the new column by running:
dbt run
- Now query the metric grouped by your new dimension by running:
mf query --metrics critical_warning_rate --group-by vehicle_type
You should see the critical warning rate broken down by each vehicle type in your fleet. This demonstrates that adding slicing capability is a YAML-only change to the semantic model.
Validation errors after adding vehicle_type?
Confirm that the vehicle_type column actually exists in your source vehicle_warnings table. If it does not exist in the raw data, the staging model cannot select it.
Also check that the YAML indentation matches the other dimensions. Each - name: entry should be at the same indent level as warning_level above it.
Help me fix MetricFlow validation errors after adding a new dimension.
✔️ Awesome, I've got everything!
Great. Make sure both stg_vehicle_warnings.sql and semantic_models.yml are saved.
ⓧ I'd like to double check the full code
Here is the full stg_vehicle_warnings.sql:
with source as (
select * from {{ source('vehicle_telemetry', 'vehicle_warnings') }}
)
select
id as warning_id,
vehicle_id,
vehicle_type,
warning_level,
event_time::timestamp as event_time,
created_at
from source
where warning_level >= 3
Here is the full semantic_models.yml:
semantic_models:
- name: vehicle_warnings
model: ref('stg_vehicle_warnings')
entities:
- name: vehicle_id
type: primary
expr: vehicle_id
dimensions:
- name: warning_level
type: categorical
expr: warning_level
- name: vehicle_type
type: categorical
expr: vehicle_type
- name: event_time
type: time
type_params:
time_granularity: day
expr: event_time
measures:
- name: warning_count
agg: count
expr: warning_id
- name: critical_warning_count
agg: count
expr: warning_id
agg_params:
where: warning_level >= 3
- name: vehicle_gps
model: ref('stg_vehicle_gps')
entities:
- name: vehicle_id
type: primary
expr: vehicle_id
dimensions:
- name: region
type: categorical
expr: region
- name: activity_date
type: time
type_params:
time_granularity: day
expr: activity_date
measures:
- name: active_vehicles
agg: count_distinct
expr: vehicle_id
- name: total_distance
agg: sum
expr: total_distance
Export metrics to a materialized table
Querying metrics locally is great for development. In production, downstream tools like Grafana or custom dashboards need a stable table they can read from.
You will create a dbt model that materializes your metric SQL as a table for downstream consumption.
- Create a new directory for your mart models by running:
mkdir vehicle_metrics/models/marts
- Confirm the directory exists by running:
ls vehicle_metrics/models/
You should see marts listed alongside staging in the output.
- Create a new file called fct_critical_warning_rate.sql inside the models/marts/ directory.
- Add the following code to fct_critical_warning_rate.sql:
-- Materialized metric table for downstream consumption
{{ config(materialized='table') }}
select
metric_time__day,
critical_warning_rate
from {{ metrics.calculate(
metrics.metric('critical_warning_rate'),
grain='day'
) }}
What does this model do?
The config(materialized='table') tells dbt to create a physical table in your database, not just a view.
The metrics.calculate call compiles the metric definition into SQL and produces a result set with one row per day. Downstream tools can query this table directly without knowing anything about your semantic model definitions.
- Save fct_critical_warning_rate.sql.
- Materialize the mart table by running:
dbt run
You should see dbt compile and execute the mart model, creating a new fct_critical_warning_rate table in your database.
Model compilation error?
Check that the metrics.calculate syntax matches the code above exactly, including the parentheses and indentation. YAML and Jinja are sensitive to whitespace.
If you see an error about the metrics macro not being found, make sure your dbt-metricflow package is installed correctly by checking dbt --version.
Help me debug a dbt compilation error in my mart model.
✔️ Awesome, I've got everything!
Great. Make sure you have saved fct_critical_warning_rate.sql in the models/marts/ directory.
ⓧ I'd like to double check the full code
Here is the full fct_critical_warning_rate.sql:
-- Materialized metric table for downstream consumption
{{ config(materialized='table') }}
select
metric_time__day,
critical_warning_rate
from {{ metrics.calculate(
metrics.metric('critical_warning_rate'),
grain='day'
) }}
Your metrics layer is fully operational. You have proven the end-to-end workflow: change a definition, rebuild, and see the results propagate to both ad-hoc queries and materialized tables. Next up, you will extend this foundation with a Secret Mission challenge.
Secret mission
Add a Derived Metric and CI Validation
Your metrics layer works locally. Now take it further by creating a cross-model derived metric that combines data from two semantic models, and set up a GitHub Actions workflow that automatically validates metric definitions on every pull request. This turns your project into a true multi-team collaboration platform.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely in Docker containers on your local machine, so there are no ongoing cloud costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Docker containers (db and dbt) and the pgdata volume.
- Local project directory (vehicle-metrics/ on your Desktop).
- GitHub repository (remote) with the GitHub Actions workflow.
Keep everything running
No action needed. Choose this if you are still actively building on your metrics layer or plan to add more metrics soon.
- Your Docker containers stay running in the background. They use minimal resources when idle.
- The PostgreSQL data persists in the pgdata volume, ready to query.
- Your GitHub repository continues accepting pull requests with automated metric validation.
Pause - I'll come back to this later
Stop the containers to free up memory and CPU. Your data and files stay intact for when you return.
- Exit the dbt container shell (if you are still inside) by running:
exit
- Stop both containers by running from the vehicle-metrics folder:
docker compose stop
- When you come back, start the containers again with docker compose start, then shell in with docker compose exec dbt bash.
Delete - I don't want to use this again
Remove all project resources and start fresh. This cannot be undone.
- Exit the dbt container shell (if you are still inside) by running:
exit
- Stop and remove both containers plus the database volume by running from the vehicle-metrics folder:
docker compose down -v
- Remove the Docker image built for this project by running:
docker image rm vehicle-metrics-dbt
- Delete your local project directory by running:
macOS
cd ~/Desktop
rm -rf vehicle-metrics
Windows
cd ~\Desktop
rmdir /s /q vehicle-metrics
Linux
cd ~/Desktop
rm -rf vehicle-metrics
- Delete your GitHub repository. Go to your repository page on GitHub.
- Click Settings (the tab below the repository name).
- Scroll down to the Danger Zone section at the bottom.
- Click Delete this repository.
- Type the repository name to confirm, then click I understand the consequences, delete this repository.
Nice Work!
Nice Work!
You just built a complete metrics-as-code platform layer for your vehicle telemetry data using dbt and MetricFlow.
Your team now has a version-controlled repository where KPI definitions live as YAML files, changes propagate instantly, and anyone can propose updates through a pull request.
You've learned how to:
- Build a dbt project connected to a PostgreSQL database, with semantic models that describe vehicle telemetry data through entities, dimensions, and measures.
- Define configurable KPI metrics in version-controlled YAML files and demonstrate that changing a definition propagates instantly to computed results.
- Query metrics locally using the MetricFlow CLI with grouping by time granularity and categorical dimensions, and export results for downstream consumption.
- Secret Mission: Created a derived cross-model metric and set up GitHub Actions CI validation for metric definition changes on every pull request.
Ready to quiz yourself?