An end-to-end, containerized ELT pipeline on the Brazilian E-Commerce (Olist) dataset, built on a three-layer medallion architecture and orchestrated by Apache Airflow.
flowchart LR
subgraph SRC["Source"]
CSV["9 Olist CSVs<br/>data/raw/"]
end
subgraph PG["PostgreSQL warehouse"]
direction TB
B["<b>Bronze</b><br/>bronze.*<br/>raw, all TEXT"]
S["<b>Silver</b><br/>staging.*<br/>typed views"]
G["<b>Gold</b><br/>marts.*<br/>star schema"]
B --> S --> G
end
CSV -->|"load_raw.py<br/>(Python COPY)"| B
G --> MB["Metabase<br/>dashboards"]
subgraph AF["Apache Airflow — olist_elt_pipeline (daily)"]
T0["extract_raw"] --> T1["load_bronze"] --> T2["dbt_run_staging"] --> T3["dbt_test_staging"] --> T4["dbt_run_marts"] --> T5["dbt_test_marts"] --> T6["dbt_docs_generate"]
end
AF -.orchestrates.-> PG
The Airflow DAG (boxes at the bottom) drives the whole path; each medallion schema inside PostgreSQL adds another round of cleaning and structure.
Raw CSVs are loaded into PostgreSQL (Bronze), cleaned and typed with dbt (Silver), and modeled into a star schema (Gold) that powers Metabase dashboards. The whole flow runs on a schedule, end-to-end, inside Docker.
This is an ELT pipeline, not ETL: data lands raw first, then every transformation happens inside the warehouse with dbt. Each medallion layer adds another round of cleaning and structure:
| Layer | Schema | What it is | Materialization |
|---|---|---|---|
| Bronze | bronze |
Exact copy of source CSVs, all columns TEXT, no logic |
tables (full refresh) |
| Silver | staging |
Cleaned, renamed, correctly-typed | views |
| Gold | marts |
Star schema (fact + dimensions) for BI | tables |
| Warehouse | PostgreSQL 16 |
| Transformations | dbt 1.8 (dbt-postgres) |
| Orchestration | Apache Airflow 3.0.1 (LocalExecutor) |
| BI | Metabase |
| Packaging | Docker Compose |
The Brazilian E-Commerce Public Dataset by Olist: about 100K orders placed between 2016 and 2018 across multiple Brazilian marketplaces, spread over 9 related CSV files.
Data provenance: the canonical source is Kaggle (link above). For this build the files were pulled from a public Hugging Face mirror (
aviahYadler/Olist_Ecommerce_Dataset) because the build environment had no Kaggle credentials.download_data.pyverifies every file against both its canonical Olist row count and a pinned SHA-256 checksum, so a tampered or swapped mirror is caught before it loads.
| File | Bronze table | Rows |
|---|---|---|
olist_orders_dataset.csv |
orders |
99,441 |
olist_customers_dataset.csv |
customers |
99,441 |
olist_order_items_dataset.csv |
order_items |
112,650 |
olist_order_payments_dataset.csv |
order_payments |
103,886 |
olist_order_reviews_dataset.csv |
order_reviews |
99,224 |
olist_products_dataset.csv |
products |
32,951 |
olist_sellers_dataset.csv |
sellers |
3,095 |
olist_geolocation_dataset.csv |
geolocation |
1,000,163 |
product_category_name_translation.csv |
product_category_name_translation |
71 |
| Total | 1,550,922 |
Gold models the order-item fact at the order-item grain, surrounded by four conformed dimensions (a classic star schema). This is the entity-relationship model that the Metabase dashboards query:
erDiagram
dim_customers ||--o{ fct_order_items : customer_id
dim_products ||--o{ fct_order_items : product_id
dim_sellers ||--o{ fct_order_items : seller_id
dim_date ||--o{ fct_order_items : order_purchase_date_key
fct_order_items {
text order_item_key PK
text order_id
int order_item_id
text customer_id FK
text product_id FK
text seller_id FK
int order_purchase_date_key FK
text order_status
numeric price
numeric freight_value
numeric item_total_value
numeric delivery_days
numeric delivery_vs_estimate_days
}
dim_customers {
text customer_id PK
text customer_unique_id
text customer_city
text customer_state
}
dim_products {
text product_id PK
text product_category
text product_category_pt
}
dim_sellers {
text seller_id PK
text seller_city
text seller_state
}
dim_date {
int date_key PK
date date
int year
int month
bool is_weekend
}
| Table | Grain | Rows |
|---|---|---|
fct_order_items |
one item line per order | 112,650 |
dim_customers |
customer_id (per-order key) | 99,441 |
dim_products |
product_id | 32,951 |
dim_sellers |
seller_id | 3,095 |
dim_date |
one calendar day | 774 |
DATA_ENG_ALP/
├── docker-compose.yml # the full stack (warehouse, airflow, metabase)
├── .env.example # config template (copy to .env)
├── container/
│ ├── airflow.Dockerfile # Airflow image + isolated dbt venv
│ ├── requirements.txt # ingestion deps (psycopg2) for Airflow's env
│ ├── dbt-requirements.txt # fully-pinned dbt lockfile
│ └── dags/
│ └── olist_pipeline.py # the Airflow DAG
├── ingestion/
│ ├── download_data.py # Extract: fetch + verify the 9 CSVs (row count + SHA256)
│ └── load_raw.py # Bronze loader (idempotent COPY)
├── dbt/
│ ├── dbt_project.yml
│ ├── profiles.yml # connection via env vars
│ ├── macros/
│ │ └── generate_schema_name.sql
│ └── models/
│ ├── staging/ # Silver: 8 stg_*.sql + tests (_staging.yml)
│ └── marts/ # Gold: dim_*/fct_* + tests (_marts.yml)
├── data/raw/ # the 9 Olist CSVs (gitignored)
└── credentials/
Prerequisites: Docker + Docker Compose, Python 3 (only for the data-download helper), and an internet connection on first run.
# 1. Get the raw data (downloads the 9 Olist CSVs into data/raw/, verifies them)
python ingestion/download_data.py
# 2. Configure (macOS / Linux)
cp .env.example .env
echo "AIRFLOW_UID=$(id -u)" >> .env # so bind-mounted logs stay writable
# 3. Launch the whole stack
docker compose up -d
# 4. Open the UIs
# Airflow → http://localhost:8000 (admin / admin)
# Metabase → http://localhost:3000 (first-run setup wizard)
# 5. Run the pipeline
# In Airflow: enable the `olist_elt_pipeline` DAG and click ▶ Trigger.
# All 7 tasks complete end-to-end in ~1–2 minutes.Windows (PowerShell): steps 1, 3, 4 and 5 are identical; only the configure step differs (no
cp/id -u):# 2. Configure Copy-Item .env.example .env # AIRFLOW_UID is only needed for Linux bind-mount permissions. On Docker # Desktop (Windows/macOS) the default 50000 already works, so leave .env as-is.
The data is not committed to git (it's large and gitignored), so step 1 is required on a fresh clone. The download script is idempotent: files already present with a matching row count and SHA-256 checksum are skipped.
Ports note: this project intentionally uses non-default host ports (warehouse
5442, Airflow8000) to avoid clashing with anything already bound to the usual5432/8080. All values live in.env.
After a successful trigger you should see the DAG complete all seven tasks, and
the Metabase dashboards populate from the Gold (marts) schema.
All seven tasks run in sequence and finish green
(extract_raw → load_bronze → dbt_run_staging → dbt_test_staging → dbt_run_marts → dbt_test_marts → dbt_docs_generate):
Built on the marts (Gold) schema; three charts answer concrete business
questions.
Metabase connection note: Metabase runs inside the Docker network, so connect it to the warehouse using host
warehouseand port5432(the internal port) — notlocalhost:5442.
1. Revenue by Product Category — where the money comes from.
2. Monthly Revenue Trend — revenue growth across 2016–2018.
3. Delivery Performance by State — average delivery days by customer state.
A pure-Python loader that COPYs each CSV into the bronze schema with all
columns as TEXT, an untransformed copy of the source. It is idempotent:
every run drops and recreates each table, so re-running always yields the same
state (a full refresh). COPY is used for speed (the geolocation file alone is
~1M rows).
Eight dbt views (no data stored) that sit on top of Bronze. Each one casts types, renames columns, and joins in trivial lookups (products gets its English category name here).
stg_orders stg_order_items stg_customers stg_sellers
stg_products stg_order_payments stg_order_reviews stg_geolocation
A star schema materialized as physical tables: one fact at the order-item
grain, surrounded by four conformed dimensions. fct_order_items is
materialized incremental (on order_purchase_timestamp, keyed by
order_item_key). A --full-refresh builds every row, while a normal daily run
only (re)loads orders at or after the high-water mark and delete+inserts them,
so re-running the same day is idempotent and cheap.
The entity-relationship diagram and per-table row counts for this star schema are shown above under Dataset → Data model.
The Airflow DAG olist_elt_pipeline runs the whole flow on a @daily
schedule, one task after another. Extract and load are included, so the entire
ingestion-to-docs path is orchestrated, not just the transforms. If any
data-quality test fails the run stops before anything reaches Gold.
extract_raw → load_bronze → dbt_run_staging → dbt_test_staging
→ dbt_run_marts → dbt_test_marts → dbt_docs_generate
extract_raw: runsdownload_data.py(idempotent: skips files already present with the right row count + checksum). Needs outbound internet only on the first run.load_bronze: full-refresh COPY intobronze.dbt_run/test_*: build + test Silver, then Gold.dbt_docs_generate: builds the dbt docs site (manifest.json+catalog.json).
The graph-view screenshot of a successful run is shown under Expected Output.
Tests are defined in dbt (_staging.yml, _marts.yml) and run as dedicated
DAG tasks. 49 data tests across the Silver and Gold layers, all passing:
- Primary keys:
unique+not_nullon every dimension PK and the fact's surrogate key. - Referential integrity:
relationshipstests on all four foreign keys fromfct_order_itemsto its dimensions. - Domain checks:
accepted_valuesonorder_status(8 valid states),payment_type, andreview_score(1–5). - Documented quirks:
review_idis intentionally not tested for uniqueness (the Olist source legitimately repeats it across orders).
# run all transformations + tests manually (outside Airflow)
docker compose exec airflow-scheduler /opt/dbt-venv/bin/dbt build \
--project-dir /opt/airflow/dbt --profiles-dir /opt/airflow/dbtThe DAG's final task (dbt_docs_generate) builds the dbt docs site
(target/manifest.json + catalog.json) on every run. To browse the
auto-generated model/column lineage and descriptions locally:
docker compose exec airflow-scheduler /opt/dbt-venv/bin/dbt docs generate \
--project-dir /opt/airflow/dbt --profiles-dir /opt/airflow/dbt
docker compose exec airflow-scheduler /opt/dbt-venv/bin/dbt docs serve \
--project-dir /opt/airflow/dbt --profiles-dir /opt/airflow/dbt --port 8080Reading the three Gold-backed dashboards together:
- Revenue is concentrated in a handful of categories. A small set of product categories (health & beauty, watches & gifts, bed/bath/table, sports & leisure) drives a disproportionate share of total revenue — a classic long-tail distribution where the top ~10 categories dominate and the remaining dozens contribute marginally.
- The business grew strongly across 2016–2018. The monthly revenue trend climbs steeply through 2017 and into 2018 from a near-zero 2016 base, with the tail months thinning out as the dataset's coverage ends — consistent with Olist's real growth over the period.
- Delivery performance varies widely by state. Average delivery time is fastest in the southeastern states near the sellers (e.g. São Paulo) and markedly slower for remote northern/north-eastern states, where orders travel much farther — a clear logistics signal for where to add fulfilment capacity.
Conclusion. The pipeline successfully turns nine raw, untyped CSVs into a trustworthy, queryable star schema: the medallion layers separate raw landing from cleaning from business modeling, dbt tests gate every layer so bad data never reaches Gold, and Airflow orchestrates the entire extract-to-docs path on a schedule. The resulting Gold tables answer real commercial questions (what sells, how revenue trends, where delivery lags) directly in Metabase without any further hand-wrangling.
- Static, historical dataset. Olist is a fixed 2016–2018 dump, so the
@dailyschedule has no genuinely new data to ingest. The schedule demonstrates idempotent re-runs (Bronze full-refresh, incremental fact) rather than true incremental capture; on a live feed the incremental fact would pick up only the new slice, but here it is a no-op after the first load. - Geolocation & reviews are excluded from Gold. Both exist in Bronze/Silver
but don't fit the order-item grain cleanly (geolocation has ~1M rows with no
clean FK;
review_idlegitimately repeats across orders), so they're kept out of the star schema. dim_customersis keyed at the per-order grain. Olist generates a freshcustomer_idper order, so the dimension matches the fact's grain rather than the real person. The true-person key (customer_unique_id) is carried as an attribute, so unique-buyer counts require acount(distinct …).- No production hardening. As a course project, tasks use
retries: 1with no alerting, no DAG SLAs, and nodbt source freshnesschecks. Production would addon_failure_callback(Slack/email), backoff retries, and freshness monitoring. - Single-node, local-only. The stack runs on Airflow's
LocalExecutorin Docker Compose on one machine — fine for this dataset, but not horizontally scalable and not a deployment topology.
Choices made and why — distinct from Known Limitations above, which lists what the project deliberately does not do.
- Idempotent materialization (full-refresh Bronze + incremental Gold). Bronze
drops and reloads each table every run, while
fct_order_itemsis an incremental model onorder_purchase_timestamp(keyed byorder_item_key,delete+insert). The point isn't to skip work on this static dataset — it's that any scheduled re-run yields the exact same warehouse state, which is the property a daily pipeline must have. The first run builds all history; later runs only touch the high-water-mark slice (and--full-refreshrebuilds from scratch on demand). - dbt in an isolated venv. dbt is installed into its own
/opt/dbt-venvinside the Airflow image (from a fully-pinneddbt-requirements.txtlockfile) so its dependencies never collide with Airflow's pinned constraint set. The DAG calls it by absolute path. - Airflow 3 specifics. v3 splits the API server, scheduler, and
dag-processor into separate services and requires
AIRFLOW__CORE__EXECUTION_API_SERVER_URLand a sharedAIRFLOW__API_AUTH__JWT_SECRETacross containers for task execution to work.
| Service | Container | Host URL / port | Credentials |
|---|---|---|---|
| Airflow UI | olist_airflow_apiserver |
http://localhost:8000 | admin / admin |
| Metabase | olist_metabase |
http://localhost:3000 | set on first visit |
| Warehouse (Postgres) | olist_warehouse |
localhost:5442 |
olist / olist (db olist) |
| Airflow metadata DB | olist_airflow_db |
internal | airflow / airflow |
docker compose ps # service status
docker compose logs -f <service> # tail a service
docker compose down # stop (keep data)
docker compose down -v # stop + wipe all data volumesdocker exec -it -e PGPASSWORD=olist olist_warehouse psql -U olist -d olist
# then: \dn (schemas) \dt marts.* (gold tables)


