Building a Freight Data Platform
with Databricks, dbt & Airflow
PySpark ingests PostgreSQL data into Delta tables. dbt organizes source-facing transformations and analytical marts. Airflow coordinates the batch workflow through Astronomer Cosmos.
Platform overview
End-to-end architecture, layer mapping, orchestration and lineage.
Why combine Medallion and dbt layers?
| Perspective | Question answered | FreightFlow implementation |
|---|---|---|
| Medallion Bronze / Silver / Gold | How far has data been processed and refined? | Delta Bronze → logical Silver transformations → Gold analytical marts. |
| dbt model structure Staging / Intermediate / Marts | What responsibility does each SQL model have? | Source-facing views → reusable transformation tables → facts, dimensions and aggregate. |
| Deployment targets dev / ci / prod | Which catalog receives a build or validation run? | freight_dev / freight_ci / freight_prod configured in dbt profiles. |
bronze, staging, intermediate, marts and observability; separate physical silver and gold schemas are not required.Why Medallion?
It communicates the platform-level refinement path, including ingestion outside dbt. It does not prescribe which code framework must implement each step.
Why dbt model layers?
They separate source normalization, reusable SQL logic and published analytical entities. dbt manages dependencies through source() and ref().
Trace one delivery event
The staged model converts a source flag to boolean. The intermediate model recomputes delivery status and tracks mismatches. The fact model adds date and facility keys.
Explore: staging SQL · intermediate SQL · Gold fact SQL
Bronze · Ingestion engineering
PostgreSQL → Databricks PySpark → Delta Bronze.
PostgreSQL / oltp
Six reference tables and eight transaction or metric tables.
PySpark + JDBC
Databricks notebook constructs source queries and reads them through Spark JDBC.
Delta Bronze
Tables at {catalog}.bronze.{table}, with ingestion metadata.
Loading policy
Overwrite — 6 tables
customers, trucks, trailers, facilities, routes, drivers
Watermark — 8 tables
loads, trips, delivery_events, fuel_purchases, maintenance_records, safety_incidents, driver_monthly_metrics, truck_utilization_metrics
Implementation & decisions
Inspect when neededActual Spark JDBC ingestion pattern
if where_clause:
query += f" WHERE {where_clause}"
query += f") AS {table}"
return (
spark.read.format("jdbc")
.option("url", jdbc_url())
.option("dbtable", query)
.option("user", ghost_user)
.option("password", ghost_password)
.option("driver", "org.postgresql.Driver")
.load()
)
df = (
df.withColumn("_ingested_at", F.current_timestamp())
.withColumn("_source_file", F.lit(f"ghost_postgres.oltp.{table}"))
)
target = f"{target_catalog}.bronze.{table}"
df.write.format("delta").mode(write_mode).option("mergeSchema", "true").saveAsTable(target)
print(f"table={table} mode={write_mode} rows_read={rows_read}")
for t in ALL_TABLES:Why JDBC, overwrite and watermark append?
- JDBC: a direct batch route from the existing PostgreSQL source.
- Overwrite: a simple current-state refresh for relatively small reference entities.
- Watermark append: reads rows with a source timestamp later than the latest loaded timestamp.
Trade-off: a watermark is not CDC. Corrections to older records, deletions and equal-timestamp arrivals may require reconciliation or lookback + merge.
Validation scope and failure modes
- The notebook logs
rows_readand adds_ingested_atand a logical_source_fileidentifier. - Delta writes configure
mergeSchema=true; this does not constitute a data contract. - The submitted source defines the write logic; ingestion throughput, retry idempotency and late-update recovery are not benchmarked here.
Source: databricks/nb_ingest_bronze.py
Silver · dbt transformations
Staging prepares source-aligned views; intermediate models apply reusable SQL logic.
14 dbt views
- Read declared Bronze relations with
source(). - Apply minimal type normalization and consistent source-facing names.
- Example: string-to-boolean conversion for
on_time_flag.
view in dbt_project.yml.Tables + exception models
- Calculate timestamp-based delivery status and mismatch flags.
- Enrich load records before marts.
- Track timestamp anomalies, flag mismatches and orphan references.
table.Why two dbt layers inside Silver?
Staging isolates source-facing normalization. Intermediate carries reusable business logic and joins. Keeping these responsibilities separate gives marts cleaner inputs and makes the transformations easier to test independently. Smaller projects can omit intermediate when it adds no useful boundary.
Staging: SQL example and source configuration
select
{{ dbt_utils.star(source('bronze', 'delivery_events'), except=["on_time_flag"]) }},
case
when lower(on_time_flag) = 'true' then true
when lower(on_time_flag) = 'false' then false
end as on_time_flag
from {{ source('bronze', 'delivery_events') }}Staging views avoid persisting lightweight projections. The trade-off is repeated SQL evaluation when downstream models run.
Source declarations and freshness thresholds are configured in dbt/models/staging/_staging__sources.yml.
Intermediate: SQL example and reusable macro
{{ config(materialized="table", tags=["intermediate"], event_time="actual_datetime") }}
select
event_id,
load_id,
trip_id,
event_type,
facility_id,
scheduled_datetime,
actual_datetime,
detention_minutes,
on_time_flag as on_time_flag_raw,
{{ recompute_on_time("actual_datetime", "scheduled_datetime") }}
as is_on_time_computed,
on_time_flag
<> {{ recompute_on_time("actual_datetime", "scheduled_datetime") }}
as flag_mismatch,
location_city,
location_state
from {{ ref("stg_freight__delivery_events") }}The model preserves the original flag and adds a computed flag and mismatch indicator. It calls a reusable dbt macro.
Exception tracking and downstream boundaries
The following models record anomalies for inspection:
They do not automatically prevent every flagged record from reaching all Gold facts. An exclusion policy must be implemented in downstream SQL or a publication gate.
Folder: dbt/models/intermediate/
Gold · Data model & marts
Four facts, seven dimensions, one aggregate and separate historical snapshots.
The Gold layer publishes four fact models, seven dimensions and a daily aggregate. Each fact has a defined intended grain; production grain uniqueness still requires data tests and runtime evidence.
| Fact model | Intended grain | Actual direct dimension joins | Materialization |
|---|---|---|---|
| fct_load | 1 row / load | customer, route, date | Incremental merge |
| fct_delivery_event | 1 row / event | facility, date | Incremental monthly microbatch |
| fct_fuel_purchase | 1 row / purchase | truck, driver, date | Incremental append |
| fct_shipment_lifecycle | 1 row / load lifecycle | None (direct Gold joins) | Incremental merge + contract |
Dimension models
The seventh dimension, dim_trailer, has no direct FK join from these four fact SQL selections.
Why dimensional modeling?
Grain-specific facts support defined event and transaction measures; conformed dimensions organize reusable descriptors for analysis. Join cardinality must be checked to protect aggregations.
Historical attributes
dbt snapshots track selected driver and truck changes using the check strategy. Current mart dimensions still read staging; historical as-of joins are not implemented in these marts.
Merge, microbatch and append: implementation comparison
- Merge / fct_load: configured with
unique_key=load_id; updates intended load-level rows. - Microbatch / fct_delivery_event: configured by
actual_datetimein monthly batches. - Append / fct_fuel_purchase: uses a max purchase-date filter; corrected older rows may not be reprocessed.
- Materialized view / agg_daily_ontime: daily delivery aggregates.
Validation boundary: incremental keys and contracts do not independently prove fact grain uniqueness.
{{
config(
materialized="incremental",
incremental_strategy="merge",
unique_key="load_id",
on_schema_change="append_new_columns",
partition_by=["load_month"],
tags=["marts"],
)
}}
selectGrain risk in fct_shipment_lifecycle
Several CTEs join on load_id. If trips, pickup events or delivery events contain multiple rows per load, the join can multiply lifecycle rows. A unique_key setting does not deduplicate those upstream records automatically.
Consider windowed selection, aggregate-by-grain or explicit uniqueness tests when improving the model.
with
pickup_events as (
select
load_id,
scheduled_datetime as pickup_scheduled,
actual_datetime as picked_up_at
from {{ ref("int_delivery_events_cleaned") }}
where event_type = 'Pickup'
),
delivery_events as (
select
load_id,
scheduled_datetime as delivery_scheduled,
actual_datetime as delivered_at
from {{ ref("int_delivery_events_cleaned") }}
where event_type = 'Delivery'
),
trip_dispatch as (
select
load_id,
dispatch_date as dispatched_at,
trip_id
from {{ ref("stg_freight__trips") }}
),
trip_fuel_cost as (
select
t.load_id,
sum(fp.total_cost) as fuel_cost
from {{ ref("stg_freight__trips") }} as t
inner join {{ ref("stg_freight__fuel_purchases") }} as fp on t.trip_id = fp.trip_id
group by 1
)
Snapshot and contract code
{% snapshot snap_drivers %}
{{
config(
target_schema="marts",
unique_key="driver_id",
strategy="check",
check_cols=["employment_status", "termination_date", "home_terminal"],
hard_deletes="new_record",
)
}}
select *
from {{ ref("stg_freight__drivers") }}
{% endsnapshot %}The lifecycle contract is declared here: dbt/models/marts/_marts__models.yml. The Gold aggregate definition is dbt/models/marts/agg_daily_ontime.sql.
Inside the dbt project
16 repository components, from source() and ref() to snapshots, tests and hooks.
This section explains dbt mechanisms used across Silver and Gold. Expand any component to inspect its responsibility, design rationale and an excerpt from the submitted repository.
01 · ConfigurationProject configurationControls model paths, schemas, materializations, access and on-run-end hooks.
Reason: A single project config makes model placement and build behavior inspectable.
clean-targets: # directories to be removed by `dbt clean`
- "target"
- "dbt_packages"
# Configuring models
# Full documentation: https://docs.getdbt.com/docs/configuring-models
# In this example config, we tell dbt to build all models in the example/
# directory as views. These settings can be overridden in the individual model
# files using the `{{ config(...) }}` macro.
models:
freight:
+persist_docs:
# Push model description as a database table comment
relation: true
# Push column descriptions as database column comments
columns: true
staging:
+schema: staging
+materialized: view
observability:
+schema: observability
intermediate:
+schema: intermediate
+group: core
+access: protected
marts:
+schema: marts
+group: core02 · LineageSource definitionsDeclares Bronze sources in the active catalog; freshness thresholds are configured.
Reason: source() references a declared external relation.
version: 2
sources:
- name: bronze
database: "{{ target.catalog }}"
schema: bronze
tables:
- name: customers
config:
loaded_at_field: _ingested_at
- name: trucks
config:
loaded_at_field: _ingested_at
- name: trailers
config:
loaded_at_field: _ingested_at
- name: facilities
config:
loaded_at_field: _ingested_at
- name: routes
config:
loaded_at_field: _ingested_at
- name: drivers
config:
loaded_at_field: _ingested_at
- name: loads
config:03 · Lineageref() & model DAGConnects staging to reusable intermediate transformations.
Reason: dbt resolves dependencies and model locations through ref().
{{ config(materialized="table", tags=["intermediate"], event_time="actual_datetime") }}
select
event_id,
load_id,
trip_id,
event_type,
facility_id,
scheduled_datetime,
actual_datetime,
detention_minutes,
on_time_flag as on_time_flag_raw,
{{ recompute_on_time("actual_datetime", "scheduled_datetime") }}
as is_on_time_computed,
on_time_flag
<> {{ recompute_on_time("actual_datetime", "scheduled_datetime") }}
as flag_mismatch,
location_city,
location_state
from {{ ref("stg_freight__delivery_events") }}04 · TransformsMaterializationsStaging views, intermediate tables, incremental facts and one materialized-view aggregate.
Reason: Selection changes compute timing, storage and update behavior.
{{
config(
materialized="incremental",
incremental_strategy="microbatch",
event_time="actual_datetime",
batch_size="month",
begin="2022-01-01",
on_schema_change="append_new_columns",
tags=["marts"],
)
}}
select05 · TransformsMacros & JinjaCentralizes the timestamp-based on-time expression.
Reason: Reused SQL expressions can be tested without copying logic to every model.
{% macro recompute_on_time(actual_col, scheduled_col) %}
case
when {{ actual_col }} is null or {{ scheduled_col }} is null
then null
when {{ actual_col }} <= {{ scheduled_col }}
then true
else false
end
{% endmacro %}06 · QualityGeneric testsContains a custom relationship-quality test with a threshold.
Reason: Generic tests parameterize checks across model columns.
{% test not_orphaned(model, column_name, to, field, threshold_pct=0) %}
with
orphans as (
select {{ column_name }} as key_value
from {{ model }}
where
{{ column_name }} is not null
and {{ column_name }} not in (select {{ field }} from {{ to }})
),
counts as (
select
(select count(*) from orphans) as orphan_count,
(select count(*) from {{ model }}) as total_count
)
select o.*
from orphans o
cross join counts c
where c.orphan_count > c.total_count * {{ threshold_pct }} / 100.0
{% endtest %}07 · QualitySingular testsDefines a query that returns records violating an assertion.
Reason: Keeps checks for specific SQL behavior in version control.
-- depends_on: {{ ref('qtn_flag_mismatch') }}
select c.event_id
from {{ ref("int_delivery_events_cleaned") }} c
left join {{ ref("qtn_flag_mismatch") }} q on c.event_id = q.event_id
where c.flag_mismatch = true and q.event_id is null08 · QualityUnit testsSpecifies controlled inputs and expected outputs for transformation logic.
Reason: Tests a model transformation independently of the complete dataset.
unit_tests:
- name: test_recompute_on_time_logic
model: int_delivery_events_cleaned
given:
- input: ref('stg_freight__delivery_events')
rows:
- {
event_id: E1,
load_id: L1,
trip_id: T1,
event_type: Pickup,
facility_id: F1,
scheduled_datetime: "2024-01-01 10:00:00",
actual_datetime: "2024-01-01 09:00:00",
detention_minutes: 0,
on_time_flag: false,
location_city: Dallas,
location_state: TX,
}
- {
event_id: E2,
load_id: L2,
trip_id: T2,
event_type: Delivery,
facility_id: F2,09 · QualitySeedsCSV rules metadata read by the quality-rule builder.
Reason: Treats rule definitions as versioned project inputs.
rule_id,model_name,column_name,rule_type,rule_expression,threshold_pct,severity,owner,description
DQ001,stg_freight__loads,load_id,not_null,,0,error,data-team,Primary key must not be null
DQ002,stg_freight__trips,driver_id,not_orphaned,stg_freight__drivers.driver_id,2,warn,data-team,Trip references a driver_id not present in drivers
DQ003,stg_freight__trips,truck_id,not_orphaned,stg_freight__trucks.truck_id,2,warn,data-team,Trip references a truck_id not present in trucks
DQ004,stg_freight__fuel_purchases,gallons,in_range,0:300,0.1,warn,finance,Fuel volume outside plausible range10 · LifecycleSnapshotscheck strategy tracks changes to selected driver attributes.
Reason: Snapshot history is separate from current-state driver dimension.
{% snapshot snap_drivers %}
{{
config(
target_schema="marts",
unique_key="driver_id",
strategy="check",
check_cols=["employment_status", "termination_date", "home_terminal"],
hard_deletes="new_record",
)
}}
select *
from {{ ref("stg_freight__drivers") }}
{% endsnapshot %}11 · OperationsObservabilityComputes rule results and failure percentages in an observability model.
Reason: Profile and rule results support inspection; not automatically a Gold filter.
-- depends_on: {{ ref('stg_freight__loads') }}
-- depends_on: {{ ref('stg_freight__trips') }}
-- depends_on: {{ ref('stg_freight__drivers') }}
-- depends_on: {{ ref('stg_freight__trucks') }}
-- depends_on: {{ ref('stg_freight__fuel_purchases') }}
-- depends_on: {{ ref('stg_freight__delivery_events') }}
{{ config(materialized="incremental", tags=["dq"]) }}
with rule_checks as ({{ build_dq_sql() }})
select
*,
round(100.0 * failed_rows / nullif(total_rows, 0), 2) as failed_pct,
(100.0 * failed_rows / nullif(total_rows, 0)) <= threshold_pct as is_passed
from rule_checks12 · OperationsEnd-of-run hooksChecks quality outcomes after dbt has run selected resources.
Reason: A hook after execution cannot retroactively prevent earlier table writes.
{% macro dq_severity_gate() %}
-- depends_on: {{ ref('dq_rule_results') }}
{% if execute %}
{# Check if dq_rule_results exists before querying to prevent error on initial tasks #}
{%- set relation = adapter.get_relation(
database=(
target.catalog
if target.type == "databricks"
else target.database
),
schema=generate_schema_name("observability"),
identifier="dq_rule_results",
) -%}
{% if relation is not none %}
{%- set check_query -%}
select count(*) as n
from {{ ref('dq_rule_results') }}
where severity = 'error' and not is_passed
and dbt_invocation_id = '{{ invocation_id }}'
{%- endset -%}
{%- set results = run_query(check_query) -%}
{%- set failed_count = results.columns[0].values()[0] if results else 0 -%}13 · QualityContractsEnforced contract is defined for fct_shipment_lifecycle.
Reason: Schema/type contracts and row-level tests solve different problems.
version: 2
models:
- name: fct_shipment_lifecycle
config:
contract:
enforced: true
columns:
- name: load_id
data_type: string
constraints:
- type: not_null
- type: primary_key
- name: booked_at
data_type: timestamp
- name: dispatched_at
data_type: timestamp
- name: picked_up_at
data_type: timestamp
- name: delivered_at
data_type: timestamp
- name: hrs_book_to_dispatch
data_type: double
- name: hrs_dispatch_to_pickup14 · MetadataExposures & docsDeclares downstream consumers in the dbt manifest.
Reason: Exposure metadata represents a declared relationship, not live proof of integration.
version: 2
exposures:
- name: freight_sla_dashboard
type: dashboard
maturity: high
url: "{{ var('sla_dashboard_url', 'https://replace-with-real-url') }}"
description: >
SLA dashboard used by the operations team — on-time rate by route/customer/driver,
cost-per-mile, data quality scorecard.
depends_on:
- ref('fct_shipment_lifecycle')
- ref('fct_load')
- ref('dim_customer')
- ref('dim_route')
owner:
name: Huu Toan
email: toandata.work@gmail.com15 · ConfigurationSchema routingOverrides default schema naming to use the configured custom schema.
Reason: Changes the physical destination of dbt models.
{% macro generate_schema_name(custom_schema_name, node) -%}
{%- set default_schema = target.schema -%}
{%- if custom_schema_name is none -%}
{{ default_schema }}
{%- else -%}
{{ custom_schema_name | trim }}
{%- endif -%}
{%- endmacro %}16 · ConfigurationGroups & accessDefines project ownership grouping used by model access configuration.
Reason: dbt model access does not establish cloud IAM enforcement.
groups:
- name: core
owner:
name: coreOrchestration, environments & CI/CD
Scheduling, dbt-aware task graphs, validation workflows and deployment targets.
Daily orchestration
freight_dailyis scheduled at0 6 * * *in scheduler timezone.- Airflow triggers a Databricks ingestion job and waits for it.
- Cosmos builds the
dbt_freighttask group and dbt dependencies. - Its config sets
TestBehavior.AFTER_ALL.
Environment targets
- dev →
freight_dev - ci →
freight_ci - prod →
freight_prod
Catalog configuration alone does not prove compute, network or IAM isolation.
Airflow failure behavior: inspect code
Includes a graceful fallback for local/dev environments when cluster quota
or workspace job trigger is temporarily unavailable.
"""
import logging
from databricks.sdk import WorkspaceClient
logger = logging.getLogger("airflow.task")
strict_mode = os.getenv("AIRFLOW_STRICT_INGESTION", "false").lower() == "true"
try:
w = WorkspaceClient(
host=os.environ["DBX_HOST"],
token=os.environ["DBX_TOKEN"]
)
logger.info(f"Triggering Databricks ingestion job targeting catalog: {TARGET_CATALOG}")
run = w.jobs.run_now(
job_id=int(os.environ["DBX_JOB_ID"]),
notebook_params={"target_catalog": TARGET_CATALOG}
)
w.jobs.wait_get_run_job_terminated_or_skipped(run_id=run.run_id)
logger.info("Databricks Ingestion Job completed successfully.")
except Exception as e:
if strict_mode:
logger.error(f"Critical: Ingestion failed in strict mode: {e}")
raise e
logger.warning(
f"Databricks job trigger unavailable ({e}). "
f"Dev mode active: proceeding downstream with existing {TARGET_CATALOG} Bronze Delta tables."
)
dbt_tg = DbtTaskGroup(
group_id="dbt_freight",
project_config=project_config,
profile_config=profile_config,
execution_config=execution_config,
render_config=RenderConfig(test_behavior=TestBehavior.AFTER_ALL),
)
ingest_bronze() >> dbt_tg
freight_daily()With AIRFLOW_STRICT_INGESTION=false (default), a job-trigger failure can be logged while downstream dbt continues using prior Bronze tables. Strict mode raises the error.
CI / CD and backfill boundaries
| Trigger | Defined workflow | Boundary |
|---|---|---|
| PR to main | SQLFluff lint; dbt build --select marts --empty --target ci | Not a full-data integration run |
| Push to main | dbt docs generate + GitHub Pages deployment | Docs publishing, not production data promotion |
| Manual backfill | freight_backfill selects fct_delivery_event on prod target | Repeated-run correctness not shown |
Runtime code: airflow/dags/freight_daily.py · dbt/profiles.yml
Execution evidence & decisions
Reviewable implementation proof, design consequences and known validation boundaries.

What the screenshot supports
- The named DAG and its task graph were visible in Airflow.
- Historical states include both successful and failed/unfinished tasks.
- It captures a moment in run history.
What it does not establish
- Production SLA or uptime.
- Successful full-data test history.
- Throughput, freshness or operating-cost results.
Design decisions and their consequences
| Decision in code | Reason it can be useful | Engineering consequence |
|---|---|---|
| Direct Spark JDBC | Short batch ingestion path | No CDC change-log capture |
| Staging views | Low duplication for simple SQL normalization | View work may repeat downstream |
| Intermediate tables | Persist reused derived logic | Extra write/storage overhead |
| Mixed fact incrementals | Tailor writes to model grain/time | Different recovery and late-update risks |
| dbt snapshots | Retain selected attribute change history | Current mart dimensions do not use historical snapshots |
| on-run-end DQ hook | Check rule results after dbt invocation | Does not retroactively prevent mart writes |
Evidence categories: code-defined behavior · documented profiling · captured execution. No performance or runtime reliability benchmark is claimed.
Source code & documentation
Direct links to the repository, model definitions and supporting implementation notes.
The source repository is the technical reference for the claims on this page. Links below open the corresponding implementation files.