| Time | Block |
|---|---|
| 0:00 – 0:23 | 43.1 A SQL data source for DuckDB, with configuration variables |
| 0:23 – 0:48 | 43.2 Table assets at every layer |
| 0:48 – 0:55 | Break |
| 0:55 – 1:18 | 43.3 Query assets: validating derived views |
| 1:18 – 1:40 | 43.4 Batch definitions: validating one month at a time |
| 1:40 – 1:48 | Break |
| 1:48 – 2:03 | 43.5 SQL vs pandas: where the work happens |
| 2:03 – 2:30 | 43.6 Capstone: setup_gx.py + smoke_check.py |
43.1 A SQL Data Source for DuckDB (23 min)
GX connects to SQL databases through SQLAlchemy connection strings. For DuckDB, the format is:
duckdb:////absolute/path/to/macro.duckdb (four slashes = absolute path)
duckdb:///relative/path/macro.duckdb (three slashes = relative path)
Keep the path out of the project file
Hard-coding a path in great_expectations.yml would break on another machine. GX substitutes ${VARIABLE} placeholders from environment variables, or from gx/uncommitted/config_variables.yml (never committed). It's the same idea as dlt's secrets on Day 23.
export MACRO_DUCKDB_URL="duckdb:////workspaces/macro-data-course/macro.duckdb"
To make it permanent, add that line to ~/.bashrc, or create a Codespaces secret named MACRO_DUCKDB_URL. For databases with passwords (Postgres, Snowflake, …), this mechanism is how credentials stay out of Git.
Register the data source
Create quality/day43_connect.py:
from gx_helpers import get_context
context = get_context()
try:
ds = context.data_sources.get("macro_duckdb")
except Exception:
ds = context.data_sources.add_sql(
name="macro_duckdb",
connection_string="${MACRO_DUCKDB_URL}",
)
print(ds)
source .venv-gx/bin/activate
python quality/day43_connect.py
grep -A3 macro_duckdb quality/gx/great_expectations.yml
The YAML stores the placeholder, not the path. Commit it safely.
One writer at a time, again. GX only reads, but DuckDB still needs other processes not to hold a write lock. Run GX after ingestion and dbt have finished, never at the same time. On Days 48–50 the pipeline enforces this order. If you see a lock error, close any open DuckDB CLI session or running dbt command.
43.2 Table Assets at Every Layer (25 min)
A table asset points GX at one table or view. Add assets for each layer we want to protect:
ASSETS = [
# name in GX schema table
("raw_statcan_observations", "raw_dlt", "statcan_observations"),
("raw_statcan_cpi_full", "raw_dlt", "statcan_cpi_full"),
("stg_statcan__cpi", "staging", "stg_statcan__cpi"),
("int_macro__observations", "intermediate", "int_macro__observations"),
("macro_monthly", "marts", "macro_monthly"),
("fct_open_economy_daily", "marts", "fct_open_economy_daily"),
]
for asset_name, schema, table in ASSETS:
try:
ds.get_asset(asset_name)
except Exception:
ds.add_table_asset(name=asset_name, schema_name=schema, table_name=table)
print("✓", asset_name)
Why validate at several layers?
| Layer | Question GX answers there |
|---|---|
raw_dlt | Did the provider send what the contract promises? (the boundary with the outside world) |
staging / intermediate | Did our cleaning preserve the data's meaning? |
marts | Is what consumers see correct, complete and fresh? |
Validating raw data is the heart of Days 48–50: problems are cheapest to catch before they flow through dozens of models.
Your first SQL-backed validation
import great_expectations as gx
asset = ds.get_asset("macro_monthly")
try:
bd = asset.get_batch_definition("full")
except Exception:
bd = asset.add_batch_definition_whole_table(name="full")
batch = bd.get_batch()
print(batch.head())
result = batch.validate(gx.expectations.ExpectColumnValuesToBeBetween(
column="unemployment_rate", min_value=0, max_value=100))
print("Unemployment 0–100%:", result.success, result.result.get("unexpected_count"))
GX translates the expectation into SQL and DuckDB does the work: the table never gets loaded into Python. That's why SQL data sources scale to large tables.
Validating raw text data
raw_dlt.statcan_cpi_full stores every column as text (Day 18: raw layers never guess types). Expectations still work, and a raw asset is the right place to check formats:
raw = ds.get_asset("raw_statcan_cpi_full")
try:
raw_bd = raw.get_batch_definition("full")
except Exception:
raw_bd = raw.add_batch_definition_whole_table(name="full")
raw_batch = raw_bd.get_batch()
for exp in [
gx.expectations.ExpectColumnValuesToNotBeNull(column="ref_date"),
gx.expectations.ExpectColumnValueLengthsToEqual(column="ref_date", value=7), # 'YYYY-MM'
gx.expectations.ExpectTableColumnsToMatchSet(
column_set=["ref_date", "geo", "products_and_product_groups", "uom",
"vector", "value", "status"],
exact_match=False), # at least these columns exist
]:
r = raw_batch.validate(exp)
print(f"{'✓' if r.success else '✗'} {exp.type}")
The column-set expectation is a contract on the provider's schema: if StatCan renames a column, GX reports it at the boundary, before dbt fails in a confusing way three models later.
43.3 Query Assets: Validating Derived Views (23 min)
Sometimes the thing you want to validate isn't a table but a question: "the unemployment series only", or "the months where the two CPI paths disagree". A query asset wraps a SQL query.
One series from the long table
UNEMP_SQL = """
select obs_date, value
from intermediate.int_macro__observations
where series_name = 'unemployment_rate'
"""
try:
unemp = ds.get_asset("unemployment_rate")
except Exception:
unemp = ds.add_query_asset(name="unemployment_rate", query=UNEMP_SQL)
The consistency trick: validate a query of violations
Recall the "test = query returning violations" pattern (Days 18 and 36). In GX, wrap the violations query as an asset and expect zero rows:
CPI_MISMATCH_SQL = """
select v.obs_date, v.value as cpi_vector, t.cpi as cpi_table
from intermediate.int_macro__observations as v
join staging.stg_statcan__cpi as t
on t.month = v.obs_date and t.geo_code = 'CA' and t.component = 'All-items'
where v.series_name = 'cpi_all_items'
and abs(v.value - t.cpi) > 1e-9
"""
try:
mismatch = ds.get_asset("cpi_vector_vs_table_mismatches")
except Exception:
mismatch = ds.add_query_asset(name="cpi_vector_vs_table_mismatches", query=CPI_MISMATCH_SQL)
bd = mismatch.add_batch_definition_whole_table(name="full")
result = bd.get_batch().validate(gx.expectations.ExpectTableRowCountToEqual(
value=0, meta={"plan_id": "Q04", "dimension": "consistency", "severity": "critical"}))
print("CPI paths consistent:", result.success)
This implements plan check Q04 from Day 41, a check that no dbt test covered.
Query assets vs dbt models. If a derived view is useful beyond quality checks, it belongs in dbt (tested, documented, on the lineage graph). Query assets are best for quality-specific questions like "rows that violate X", which nobody else consumes.
43.4 Batch Definitions: Validating One Month at a Time (23 min)
A whole-table batch validates all history on every run. That's fine for small tables, but often you want to validate only the newest data, the month that just arrived, for three reasons:
- Speed: checking one month is cheaper than 50 years.
- Signal: a failure in the latest batch is news; a failure in 1987 is old news you've already accepted (with
mostly). - Accountability: results per batch build a validation history (Data Docs, Days 44–47).
GX can partition a table asset by a date column:
asset = ds.get_asset("stg_statcan__cpi")
try:
monthly_bd = asset.get_batch_definition("by_month")
except Exception:
monthly_bd = asset.add_batch_definition_monthly(name="by_month", column="month")
# Validate one specific month
batch = monthly_bd.get_batch(batch_parameters={"year": 2026, "month": 7})
print(batch.head())
print(batch.validate(gx.expectations.ExpectColumnValuesToBeBetween(
column="cpi", min_value=0, max_value=1000)).success)
Without batch_parameters, get_batch() returns the most recent partition, which is exactly what a daily pipeline wants.
| Batch definition | Selects | Use |
|---|---|---|
add_batch_definition_whole_table | all rows | small tables; full-history checks |
add_batch_definition_yearly(column=...) | one year | annual reviews |
add_batch_definition_monthly(column=...) | one month | monthly StatCan releases |
add_batch_definition_daily(column=...) | one day | daily market data |
Try it 43.4
Add a monthly batch definition to the unemployment_rate query asset (column obs_date) and validate the latest month: value between 0 and 100 and not null. Then validate April 2020 explicitly. What's special about it?
Solution
unemp = ds.get_asset("unemployment_rate")
try:
ubd = unemp.get_batch_definition("by_month")
except Exception:
ubd = unemp.add_batch_definition_monthly(name="by_month", column="obs_date")
for params in [None, {"year": 2020, "month": 4}]:
b = ubd.get_batch(batch_parameters=params) if params else ubd.get_batch()
r = b.validate(gx.expectations.ExpectColumnValuesToBeBetween(
column="value", min_value=0, max_value=100))
print(params or "latest", r.success, b.head()["value"].tolist())
April 2020 is valid (inside 0–100%), but it's one of the largest monthly jumps in the series' history. A range check passes, while a plausibility check on changes (Day 45) would flag it for review. That's precisely the distinction between the validity and plausibility dimensions.
43.5 SQL vs pandas: Where the Work Happens (15 min)
You now know two ways to point GX at DuckDB data:
SQL data source (add_sql) | pandas data source (query with duckdb, then add_pandas) | |
|---|---|---|
| Where computation runs | inside DuckDB (GX generates SQL) | in Python memory |
| Scales to large tables | ✓ | limited by RAM |
| Expectation coverage | most expectations; a few are pandas-only (e.g. some regex/string types, depending on the dialect) | the widest coverage |
| Setup | connection string + duckdb-engine | just a DataFrame |
| Best for | production validation of tables and views | small data, seeds, special expectations, fallback |
The fallback pattern
If a particular expectation isn't supported for DuckDB in your GX version, or you hit a connection issue, validate a DataFrame instead:
import duckdb
import great_expectations as gx
from gx_helpers import get_context, PROJECT_ROOT
context = get_context()
con = duckdb.connect(str(PROJECT_ROOT.parent / "macro.duckdb"), read_only=True)
df = con.sql("select month, headline_yoy, unemployment_rate from marts.macro_monthly").df()
con.close()
try:
src = context.data_sources.get("pandas_fallback")
except Exception:
src = context.data_sources.add_pandas(name="pandas_fallback")
try:
asset = src.get_asset("macro_monthly_df")
except Exception:
asset = src.add_dataframe_asset(name="macro_monthly_df")
try:
bd = asset.get_batch_definition("whole")
except Exception:
bd = asset.add_batch_definition_whole_dataframe(name="whole")
batch = bd.get_batch(batch_parameters={"dataframe": df})
print(batch.validate(gx.expectations.ExpectColumnValuesToMatchRegex(
column="month", regex=r"^\d{4}-\d{2}-01")).success) # dates as text would need casting first
read_only=True means this script can't accidentally modify the warehouse. Use it for every quality script that opens DuckDB directly.
(The regex example is illustrative. month is a date, so for a format check you'd first convert it to a string, e.g. df["month"].astype(str). Try it.)
43.6 Capstone: setup_gx.py + smoke_check.py (28 min)
Build two scripts that turn today's experiments into a reproducible foundation:
quality/setup_gx.py: idempotent (safe to run many times). It registers the data source, all assets and batch definitions. Run it whenever the setup changes.quality/smoke_check.py: runs a handful of critical expectations from the quality plan against production data, prints a report, and exits non-zero on any failure.
This mirrors dbt's split between project definition and running (dbt build), and Day 23's split between configuration and execution.
Requirements for smoke_check.py (plan IDs from Day 41):
| Plan ID | Asset | Expectation |
|---|---|---|
| Q01 | unemployment_rate (query) | values between 0 and 100 |
| Q04 | cpi_vector_vs_table_mismatches (query) | row count equals 0 |
| Q07 | macro_monthly | month unique and not null |
| Q08 | fct_open_economy_daily | usd_cad between 0.8 and 2.0 |
| Q09 | stg_statcan__cpi latest month | cpi not null, > 0 |
| Q10 | raw_statcan_cpi_full | required columns present (schema contract at the boundary) |
Solution — quality/setup_gx.py
"""Idempotently register the DuckDB data source, assets and batch definitions."""
from gx_helpers import get_context
TABLE_ASSETS = [
("raw_statcan_observations", "raw_dlt", "statcan_observations"),
("raw_statcan_cpi_full", "raw_dlt", "statcan_cpi_full"),
("stg_statcan__cpi", "staging", "stg_statcan__cpi"),
("int_macro__observations", "intermediate", "int_macro__observations"),
("macro_monthly", "marts", "macro_monthly"),
("fct_open_economy_daily", "marts", "fct_open_economy_daily"),
]
QUERY_ASSETS = {
"unemployment_rate": """
select obs_date, value
from intermediate.int_macro__observations
where series_name = 'unemployment_rate'
""",
"cpi_vector_vs_table_mismatches": """
select v.obs_date, v.value as cpi_vector, t.cpi as cpi_table
from intermediate.int_macro__observations as v
join staging.stg_statcan__cpi as t
on t.month = v.obs_date and t.geo_code = 'CA' and t.component = 'All-items'
where v.series_name = 'cpi_all_items' and abs(v.value - t.cpi) > 1e-9
""",
}
MONTHLY_PARTITIONS = {"stg_statcan__cpi": "month", "unemployment_rate": "obs_date"}
def main():
context = get_context()
try:
ds = context.data_sources.get("macro_duckdb")
except Exception:
ds = context.data_sources.add_sql(name="macro_duckdb",
connection_string="${MACRO_DUCKDB_URL}")
for name, schema, table in TABLE_ASSETS:
try:
asset = ds.get_asset(name)
except Exception:
asset = ds.add_table_asset(name=name, schema_name=schema, table_name=table)
ensure_batch_definitions(asset, MONTHLY_PARTITIONS.get(name))
for name, sql in QUERY_ASSETS.items():
try:
asset = ds.get_asset(name)
except Exception:
asset = ds.add_query_asset(name=name, query=sql)
ensure_batch_definitions(asset, MONTHLY_PARTITIONS.get(name))
print("✓ GX setup complete:",
sorted(a.name for a in ds.assets))
def ensure_batch_definitions(asset, month_column=None):
try:
asset.get_batch_definition("full")
except Exception:
asset.add_batch_definition_whole_table(name="full")
if month_column:
try:
asset.get_batch_definition("by_month")
except Exception:
asset.add_batch_definition_monthly(name="by_month", column=month_column)
if __name__ == "__main__":
main()
Solution — quality/smoke_check.py
"""Run critical smoke checks from the data quality plan against production data."""
import sys
import great_expectations as gx
from gx_helpers import get_context
E = gx.expectations
CHECKS = [
# (plan_id, asset, batch_definition, expectation)
("Q01", "unemployment_rate", "full",
E.ExpectColumnValuesToBeBetween(column="value", min_value=0, max_value=100)),
("Q04", "cpi_vector_vs_table_mismatches", "full",
E.ExpectTableRowCountToEqual(value=0)),
("Q07", "macro_monthly", "full",
E.ExpectColumnValuesToBeUnique(column="month")),
("Q07", "macro_monthly", "full",
E.ExpectColumnValuesToNotBeNull(column="month")),
("Q08", "fct_open_economy_daily", "full",
E.ExpectColumnValuesToBeBetween(column="usd_cad", min_value=0.8, max_value=2.0)),
("Q09", "stg_statcan__cpi", "by_month", # latest month only
E.ExpectColumnValuesToBeBetween(column="cpi", min_value=0, strict_min=True)),
("Q10", "raw_statcan_cpi_full", "full",
E.ExpectTableColumnsToMatchSet(
column_set=["ref_date", "geo", "products_and_product_groups", "uom",
"vector", "value", "status"], exact_match=False)),
]
def main():
context = get_context()
ds = context.data_sources.get("macro_duckdb")
failures = 0
print(f"{'ID':<5} {'asset':<34} {'expectation':<42} result")
for plan_id, asset_name, bd_name, exp in CHECKS:
batch = ds.get_asset(asset_name).get_batch_definition(bd_name).get_batch()
r = batch.validate(exp, result_format="SUMMARY")
detail = r.result.get("unexpected_count", r.result.get("observed_value", ""))
print(f"{plan_id:<5} {asset_name:<34} {exp.type:<42} "
f"{'✓' if r.success else '✗'} {detail}")
failures += 0 if r.success else 1
print(f"\n{failures} failing check(s)")
sys.exit(1 if failures else 0)
if __name__ == "__main__":
main()
Run them:
source .venv-gx/bin/activate
python quality/setup_gx.py
python quality/setup_gx.py # second run: nothing new created (idempotent)
python quality/smoke_check.py; echo "exit code: $?"
Break it on purpose
Prove the checks work before trusting them:
- In
smoke_check.py, temporarily set Q08'smax_valueto1.2, run, observe the failure and the count of offending days, then restore it. - Consistency failure: in a DuckDB session (with nothing else running), create a temporary copy that disagrees, point a test query asset at it, and confirm
ExpectTableRowCountToEqual(0)fails. Then drop the copy. Never edit production tables to test.
Recap: Days 41–43
| You can now… | Built |
|---|---|
| analyse failures by quality dimension and severity | Day 41 stories and audit |
| write a contract-style quality plan with owners and tools | DATA_QUALITY_PLAN.md |
| separate tool environments to avoid dependency conflicts | .venv-gx |
| explain and use the GX object model | ephemeral and file contexts |
| connect GX to DuckDB with configuration variables | macro_duckdb data source |
| validate tables, derived queries and single months at every layer | table, query and monthly assets |
| run critical checks reproducibly with exit codes | setup_gx.py, smoke_check.py |
Looking ahead: Days 44–47
- Expectation suites per asset, organised by the quality plan, with
metalinking every expectation to its plan ID, dimension and severity. - Profiling datasets to derive sensible expectations (ranges, distributions, allowed values) from history.
- Validation definitions and checkpoints with actions.
- Automated Data Docs: a browsable website of expectations and validation results for economists and managers, not just engineers.
Day 43 checklist
- GX connects to DuckDB through a
${MACRO_DUCKDB_URL}placeholder; no paths or secrets are committed - I registered table assets in
raw_dlt,staging,intermediateandmarts, and know why each layer matters - I use query assets for quality-specific questions, including a zero-rows consistency check
- I can validate the latest month, or any chosen month, with monthly batch definitions
- I know when to use SQL vs pandas data sources, and have a read-only fallback
-
setup_gx.pyis idempotent;smoke_check.pyenforces critical plan checks with an exit code
git add quality/ .gitignore
git status # quality/gx/uncommitted must NOT be listed
git commit -m "Day 43: GX connected to DuckDB, assets, batch definitions, smoke checks"
git push