| Time | Block |
|---|---|
| 0:00 – 0:15 | 21.1 Installing dlt |
| 0:15 – 0:40 | 21.2 The smallest possible pipeline |
| 0:40 – 0:48 | Break |
| 0:48 – 1:10 | 21.3 Running twice: write dispositions |
| 1:10 – 1:35 | 21.4 Python generators and yield |
| 1:35 – 1:43 | Break |
| 1:43 – 2:05 | 21.5 @dlt.resource: a real BoC resource |
| 2:05 – 2:18 | 21.6 Inspecting pipelines and loads |
| 2:18 – 2:30 | 21.7 Mini-project: a FRED resource |
21.1 Installing dlt (15 min)
cd /workspaces/macro-data-course
source .venv/bin/activate
pip install "dlt[duckdb]"
pip freeze > requirements.txt
dlt --version
The [duckdb] part installs the extra packages dlt needs for the DuckDB destination.
Add these lines to .gitignore now, before you create any secrets:
.dlt/secrets.toml
*.duckdb
*.duckdb.wal
21.2 The Smallest Possible Pipeline (25 min)
Create day21/first_pipeline.py:
import dlt
# Data: a plain Python list of dictionaries (Day 1 + Day 4!)
decisions = [
{"decision_date": "2025-01-29", "rate": 3.00, "change_bp": -25},
{"decision_date": "2025-03-12", "rate": 2.75, "change_bp": -25},
{"decision_date": "2025-04-16", "rate": 2.25, "change_bp": -50},
]
pipeline = dlt.pipeline(
pipeline_name="dlt_practice", # also the name of the DuckDB file
destination="duckdb",
dataset_name="practice", # the schema inside DuckDB
)
load_info = pipeline.run(decisions, table_name="boc_decisions")
print(load_info)
Run it:
python day21/first_pipeline.py
ls *.duckdb
dlt created dlt_practice.duckdb in your current folder. Open it with the tools you already know:
duckdb dlt_practice.duckdb
.mode box
SELECT table_schema, table_name FROM information_schema.tables ORDER BY 1, 2;
SELECT * FROM practice.boc_decisions;
DESCRIBE practice.boc_decisions;
What dlt did for you
- Created a schema (
practice) and a table (boc_decisions). - Inferred types:
rate→DOUBLE,change_bp→BIGINT, anddecision_date→ a text or date column depending on how dlt reads the string (next block). - Added bookkeeping columns:
_dlt_load_id(which run loaded this row) and_dlt_id(a unique row ID). - Created system tables:
SELECT * FROM practice._dlt_loads; -- one row per successful load
SELECT * FROM practice._dlt_version; -- schema versions
_dlt_loads replaces the fetched_at column you added by hand on Day 10: every row can be traced to the exact run that produced it.
Types: be explicit about dates
Strings that look like dates may stay text. Convert them in Python, and dlt will create a proper DATE column:
from datetime import date
decisions = [
{"decision_date": date(2025, 1, 29), "rate": 3.00, "change_bp": -25},
{"decision_date": date(2025, 3, 12), "rate": 2.75, "change_bp": -25},
{"decision_date": date(2025, 4, 16), "rate": 2.25, "change_bp": -50},
]
Or give dlt a column hint:
load_info = pipeline.run(
decisions,
table_name="boc_decisions",
columns={"decision_date": {"data_type": "date"}},
)
Quit the DuckDB CLI (
.quit) before running a pipeline that writes to the same file. DuckDB still allows only one writer at a time (Day 13).
21.3 Running Twice: Write Dispositions (23 min)
Run first_pipeline.py again, then count the rows:
SELECT COUNT(*) FROM practice.boc_decisions; -- 6, not 3!
SELECT _dlt_load_id, COUNT(*) FROM practice.boc_decisions GROUP BY 1;
By default dlt appends. For a decision log that's a bug: every run duplicates history. The write disposition controls what happens:
| Disposition | Behaviour | Use for |
|---|---|---|
"append" (default) | add new rows | event logs, snapshots you want to keep |
"replace" | drop the old data, load fresh | small tables re-downloaded in full |
"merge" | update rows with the same primary key, insert new ones | data with revisions (Days 24–27) |
load_info = pipeline.run(
decisions,
table_name="boc_decisions",
write_disposition="replace",
)
Run it twice and check that the count stays at 3.
A first look at merge
Macro data gets revised: StatCan updates past months. merge handles that when you tell dlt which columns identify a row:
revised = [
{"decision_date": date(2025, 4, 16), "rate": 2.25, "change_bp": -50,
"note": "Tariff shock"}, # updated row
{"decision_date": date(2025, 6, 4), "rate": 2.25, "change_bp": 0,
"note": "Hold"}, # new row
]
pipeline.run(revised, table_name="boc_decisions",
write_disposition="merge", primary_key="decision_date")
SELECT decision_date, rate, change_bp, note FROM practice.boc_decisions ORDER BY 1;
Notice that the table gained a note column automatically. That's schema evolution, covered in depth on Days 24–27.
Try it 21.3
Load the provinces fiscal table (Day 4) into practice.provinces with replace. Then change Ontario's balance to −1.5 and load again using merge with primary_key="province". Verify there are still 10 rows.
Solution
provinces = [
{"province": "BC", "balance_pct_gdp": -0.8}, {"province": "AB", "balance_pct_gdp": 0.2},
{"province": "SK", "balance_pct_gdp": 0.5}, {"province": "MB", "balance_pct_gdp": -0.9},
{"province": "ON", "balance_pct_gdp": -1.4}, {"province": "QC", "balance_pct_gdp": -1.2},
{"province": "NB", "balance_pct_gdp": -0.5}, {"province": "NS", "balance_pct_gdp": -0.3},
{"province": "PE", "balance_pct_gdp": -0.7}, {"province": "NL", "balance_pct_gdp": 0.1},
]
pipeline.run(provinces, table_name="provinces", write_disposition="replace")
pipeline.run([{"province": "ON", "balance_pct_gdp": -1.5}],
table_name="provinces", write_disposition="merge", primary_key="province")
SELECT COUNT(*), MAX(balance_pct_gdp) FILTER (WHERE province = 'ON') FROM practice.provinces;
21.4 Python Generators and yield (25 min)
dlt resources are usually generators, a Python feature you haven't met yet. It's worth learning slowly.
Example 1 — return gives everything at once
def all_months_list(n):
months = []
for i in range(n):
months.append(i)
return months
print(all_months_list(5)) # [0, 1, 2, 3, 4]
Example 2 — yield gives one item at a time
def months_generator(n):
for i in range(n):
print(f" (producing {i})")
yield i
gen = months_generator(3)
print(gen) # <generator object ...> nothing has run yet!
for m in gen:
print("received", m)
Output:
(producing 0)
received 0
(producing 1)
received 1
(producing 2)
received 2
The function pauses at each yield and resumes when the next item is requested. It's lazy: work happens only when someone consumes it.
Why this matters for data
Imagine downloading 50 years of daily data for 200 series. With return, everything must fit in memory before anything is saved. With yield, dlt can process and write each batch as it arrives.
def fetch_series_one_by_one(series_ids):
for sid in series_ids:
print(f"downloading {sid}...")
rows = [{"series_id": sid, "value": 1.0}] # imagine an API call here
yield rows # hand over one batch, then continue
for batch in fetch_series_one_by_one(["V39079", "FXCADUSD"]):
print("got", len(batch), "rows")
You can yield single dictionaries or lists of dictionaries (pages). Yielding lists is faster when an API returns many rows at once.
Example 3 — Generators are used up
gen = months_generator(2)
print(list(gen)) # [0, 1]
print(list(gen)) # [] — a generator can only be consumed once
Try it 21.4
Write a generator compounding_path(start, growth, years) that yields a dictionary {"year": t, "level": ...} for each year, reproducing Day 1's compound-growth example (GDP 3180, 1.5%, 5 years). Collect it with list(...).
Solution
def compounding_path(start, growth, years):
level = start
for t in range(years + 1):
yield {"year": t, "level": round(level, 1)}
level *= 1 + growth
print(list(compounding_path(3180, 0.015, 5)))
21.5 @dlt.resource: a Real BoC Resource (23 min)
A resource is a generator decorated with @dlt.resource. The decorator attaches table-level settings: name, write disposition, keys and column hints.
Create day21/boc_resource.py:
from datetime import date
import dlt
from dlt.sources.helpers import requests # requests with automatic retries
BASE = "https://www.bankofcanada.ca/valet"
@dlt.resource(
name="boc_observations",
write_disposition="replace",
primary_key=("series_id", "date"),
columns={"value": {"data_type": "double"}},
)
def boc_observations(series_ids, start_date="2006-01-01"):
"""Yield one page of rows per BoC Valet series (long format, as on Day 10)."""
for sid in series_ids:
r = requests.get(f"{BASE}/observations/{sid}/json",
params={"start_date": start_date}, timeout=30)
r.raise_for_status()
rows = []
for obs in r.json().get("observations", []):
cell = obs.get(sid)
if not isinstance(cell, dict):
continue
try:
value = float(cell.get("v"))
except (TypeError, ValueError): # '', 'NA', 'Bank holiday', None
continue
rows.append({
"date": date.fromisoformat(obs["d"]),
"series_id": sid,
"value": value,
"source": "boc",
})
yield rows
if __name__ == "__main__":
pipeline = dlt.pipeline(pipeline_name="dlt_practice",
destination="duckdb", dataset_name="practice")
info = pipeline.run(boc_observations(["V39079", "FXCADUSD"]))
print(info)
Things to notice:
- The parsing is your Day 8
parse_valetlogic. dlt doesn't replace economic knowledge. from dlt.sources.helpers import requestsis a drop-in replacement forrequeststhat retries automatically on timeouts, 429 and 5xx errors. It replaces your Day 9make_session().primary_keyis documentation now and becomes essential formergelater.- Calling
boc_observations([...])doesn't download anything yet; it creates a resource. The download happens insidepipeline.run, which is the generator's laziness at work.
python day21/boc_resource.py
duckdb dlt_practice.duckdb -c "SELECT series_id, COUNT(*), MAX(date) FROM practice.boc_observations GROUP BY 1"
21.6 Inspecting Pipelines and Loads (13 min)
The load info
print(info) # human-readable summary
print(info.has_failed_jobs) # True/False
print(pipeline.last_trace) # timings of extract / normalize / load
The dlt command line
dlt pipeline dlt_practice info # tables, schemas, last run
dlt pipeline dlt_practice trace # step-by-step timings of the last run
Reading data back into pandas
ds = pipeline.dataset()
df = ds.boc_observations.df()
print(df.groupby("series_id")["value"].describe())
Where dlt keeps its working files
ls ~/.dlt/pipelines/dlt_practice/
This folder holds the pipeline's state, its schema and recent load packages (the extracted and normalized files before loading), much like your Day 9 raw landing zone. If a pipeline gets into a confusing state during practice, you can reset it:
dlt pipeline dlt_practice drop --help # see options; or simply:
rm -rf ~/.dlt/pipelines/dlt_practice dlt_practice.duckdb
Seeing the inferred schema
print(pipeline.default_schema.to_pretty_yaml())
Find boc_observations in the output. You'll see each column's data_type, nullable, and the primary_key flags you set.
21.7 Mini-Project: A FRED Resource (13 min)
Create day21/fred_resource.py with a resource fred_observations(series_ids, start_date="2006-01-01") that:
- Downloads the FRED graph CSV for each series (Day 9), using dlt's
requestshelper. - Parses it with Python's built-in
csvmodule (no pandas needed), skipping missing values (""or".") and dates beforestart_date. - Yields long rows
{date, series_id, value, source="fred"}, one list per series. - Uses
replaceand the same primary key as the BoC resource.
Load FEDFUNDS and DCOILWTICO into the same dlt_practice pipeline and dataset, then query both tables together with UNION ALL BY NAME (Day 12).
Solution
import csv
import io
from datetime import date
import dlt
from dlt.sources.helpers import requests
URL = "https://fred.stlouisfed.org/graph/fredgraph.csv"
@dlt.resource(name="fred_observations", write_disposition="replace",
primary_key=("series_id", "date"),
columns={"value": {"data_type": "double"}})
def fred_observations(series_ids, start_date="2006-01-01"):
start = date.fromisoformat(start_date)
for sid in series_ids:
r = requests.get(URL, params={"id": sid}, timeout=30)
r.raise_for_status()
reader = csv.reader(io.StringIO(r.text))
next(reader) # skip header (name varies)
rows = []
for line in reader:
if len(line) < 2 or line[1] in ("", "."):
continue
d = date.fromisoformat(line[0])
if d >= start:
rows.append({"date": d, "series_id": sid,
"value": float(line[1]), "source": "fred"})
yield rows
if __name__ == "__main__":
pipeline = dlt.pipeline(pipeline_name="dlt_practice",
destination="duckdb", dataset_name="practice")
print(pipeline.run(fred_observations(["FEDFUNDS", "DCOILWTICO"])))
SELECT source, series_id, COUNT(*) AS n, MAX(date) AS latest
FROM (SELECT * FROM practice.boc_observations
UNION ALL BY NAME
SELECT * FROM practice.fred_observations)
GROUP BY ALL
ORDER BY source, series_id;
next(reader) pulls the first item from an iterator, here the header row, just as a generator hands over its next value.
Day 21 checklist
- I can create a pipeline, run it and inspect the result in DuckDB
- I know what
_dlt_load_id,_dlt_idand_dlt_loadsare for - I can choose between
append,replaceandmerge - I understand generators,
yieldand laziness - I can write a
@dlt.resourcewith column hints and a primary key - I can inspect loads with
load_info, the dlt CLI andpipeline.dataset()
git add .
git commit -m "Day 21: first dlt pipelines and resources"
git push