| Time | Block |
|---|---|
| 0:00 – 0:20 | 42.1 A separate environment, and why |
| 0:20 – 0:40 | 42.2 The GX mental model |
| 0:40 – 0:48 | Break |
| 0:48 – 1:13 | 42.3 A first validation in an ephemeral context |
| 1:13 – 1:38 | 42.4 Anatomy of an expectation and its result |
| 1:38 – 1:45 | Break |
| 1:45 – 2:08 | 42.5 A file-based Data Context: the GX project |
| 2:08 – 2:30 | 42.6 Mini-project: validating the BoC decision log |
42.1 A Separate Environment, and Why (20 min)
dlt, dbt and GX are large libraries with many dependencies. Installing them all into one environment risks version conflicts: one tool needs version X of a shared library, another needs Y. The robust solution is the one you learned on Day 3: one virtual environment per tool family.
cd /workspaces/macro-data-course
python -m venv .venv-gx
source .venv-gx/bin/activate
pip install --upgrade pip
pip install great_expectations duckdb duckdb-engine pandas
pip freeze > quality/requirements-gx.txt
python -c "import great_expectations as gx; print(gx.__version__)"
great_expectations: GX Core, the open-source library.duckdb-engine: lets GX (which talks to SQL databases through SQLAlchemy) connect to DuckDB.
Add .venv-gx/ to .gitignore.
| Environment | Used for | Activate with |
|---|---|---|
.venv | dlt ingestion, dbt | source .venv/bin/activate |
.venv-gx | Great Expectations | source .venv-gx/bin/activate |
⚠️ Version warning. GX changed substantially at version 1.0. Many tutorials online still show the older API (
great_expectations initon the command line, "Data Assistants",context.sources.add_...). This course uses the 1.x Python API (gx.get_context(mode=...),context.data_sources.add_...,gx.expectations.Expect...). If you find code online, check which version it targets.
42.2 The GX Mental Model (20 min)
GX organises everything in a hierarchy. Learn these words and the rest of the module becomes straightforward.
Data Context ── the GX project: configuration + stored objects
│
├── Data Source how to connect e.g. "macro_duckdb" (SQL connection)
│ └── Data Asset what to read e.g. table staging.stg_statcan__cpi
│ └── Batch Definition which slice e.g. the whole table, or one month
│ └── Batch the actual data at validation time
│
├── Expectation one testable statement "values in column X are between 0 and 100"
├── Expectation Suite a named group of expectations for one asset
│
├── Validation Definition = Batch Definition + Expectation Suite
├── Checkpoint runs validation definitions + actions (Days 44–47)
│ └── Actions e.g. update Data Docs, send a notification
│
└── Data Docs generated website of expectations and results (Days 44–47)
Mapping GX to what you know
| GX concept | Closest thing you already know |
|---|---|
| Data Context | a dbt project (dbt_project.yml) |
| Data Source | a dbt profile / connection |
| Data Asset | a dbt source table or model |
| Batch Definition | a where clause selecting part of a table |
| Expectation | a dbt generic test with parameters |
| Expectation Suite | the tests in one model's YAML file |
| Checkpoint | dbt test --select … plus what to do with results |
| Data Docs | dbt docs, but for test results over time |
42.3 A First Validation in an Ephemeral Context (25 min)
An ephemeral context lives only in memory: nothing is saved. It's perfect for learning.
Create quality/day42_first_steps.py:
import great_expectations as gx
import pandas as pd
# 1. An in-memory Data Context
context = gx.get_context(mode="ephemeral")
# 2. Some data: the notebook's provincial fiscal positions (Day 4)
df = pd.DataFrame({
"province": ["BC", "AB", "SK", "MB", "ON", "QC", "NB", "NS", "PE", "NL"],
"balance_pct_gdp": [-0.8, 0.2, 0.5, -0.9, -1.4, -1.2, -0.5, -0.3, -0.7, 0.1],
})
# 3. Data Source → Data Asset → Batch Definition → Batch
source = context.data_sources.add_pandas(name="pandas_lab")
asset = source.add_dataframe_asset(name="provinces")
batch_definition = asset.add_batch_definition_whole_dataframe(name="whole")
batch = batch_definition.get_batch(batch_parameters={"dataframe": df})
print(batch.head())
# 4. An expectation, validated against the batch
expectation = gx.expectations.ExpectColumnValuesToBeBetween(
column="balance_pct_gdp", min_value=-5, max_value=5
)
result = batch.validate(expectation)
print("Success:", result.success)
print(result.result)
source .venv-gx/bin/activate
python quality/day42_first_steps.py
You just wrote your first executable data contract clause: provincial balances lie between −5% and +5% of GDP.
Make it fail on purpose
Change Ontario's value to -14.0 (a slipped decimal point: a unit error, like Story 1) and run again. Now read result.result:
{'element_count': 10, 'unexpected_count': 1, 'unexpected_percent': 10.0,
'partial_unexpected_list': [-14.0], ...}
GX tells you how many rows failed, what share, and which values, far more than a pass/fail flag.
42.4 Anatomy of an Expectation and Its Result (25 min)
The parts of an expectation
gx.expectations.ExpectColumnValuesToBeBetween(
column="value", # what it applies to
min_value=0, # parameters of the rule
max_value=100,
mostly=0.99, # optional: minimum share of rows that must pass
meta={"dimension": "validity", "severity": "critical", "plan_id": "Q01"},
)
- Class names read as sentences:
ExpectColumnValuesToBeBetween= "expect column values to be between…". mostly(Day 41.5) tolerates a share of failures.metais free-form metadata. We'll use it to link each expectation to the quality plan (ID, dimension, severity). It appears in Data Docs.
Families of expectations
| Family | Examples | Macro use |
|---|---|---|
| Table-level | ExpectTableRowCountToBeBetween, ExpectTableRowCountToEqual, ExpectTableColumnsToMatchSet | "at least 500 months"; "zero mismatches"; "required columns present" |
| Column map (row by row) | ExpectColumnValuesToNotBeNull, ExpectColumnValuesToBeBetween, ExpectColumnValuesToBeInSet, ExpectColumnValuesToBeUnique | validity, completeness |
| Column aggregate (whole column) | ExpectColumnMeanToBeBetween, ExpectColumnMaxToBeBetween, ExpectColumnMedianToBeBetween, ExpectColumnStdevToBeBetween | distributional checks: they catch Story 1's unit error! |
| Multi-column | ExpectCompoundColumnsToBeUnique, ExpectColumnPairValuesAToBeGreaterThanB | composite keys; end_date ≥ start_date |
Result formats
result = batch.validate(expectation, result_format="COMPLETE")
result_format | Returns |
|---|---|
BOOLEAN_ONLY | just success/failure (fastest) |
BASIC | counts and a few unexpected values |
SUMMARY | counts, partial unexpected list, value counts |
COMPLETE | every unexpected value, and (for some sources) the rows |
Use SUMMARY by default. COMPLETE can be large on big tables.
Validating several expectations at once: a first suite
suite = gx.ExpectationSuite(name="provinces_suite")
suite.add_expectation(gx.expectations.ExpectColumnValuesToNotBeNull(column="province"))
suite.add_expectation(gx.expectations.ExpectColumnValuesToBeUnique(column="province"))
suite.add_expectation(gx.expectations.ExpectColumnValuesToBeInSet(
column="province",
value_set=["BC", "AB", "SK", "MB", "ON", "QC", "NB", "NS", "PE", "NL"]))
suite.add_expectation(gx.expectations.ExpectColumnValuesToBeBetween(
column="balance_pct_gdp", min_value=-5, max_value=5))
suite.add_expectation(gx.expectations.ExpectTableRowCountToEqual(value=10))
suite = context.suites.add(suite) # register with the context
results = batch.validate(suite)
print("Suite success:", results.success)
for r in results.results:
print(f"{'✓' if r.success else '✗'} {r.expectation_config.type}")
Suites get the full treatment on Days 44–47.
Try it 42.4
Add an aggregate expectation to the suite that would catch a unit error across the whole column, even if every individual value stayed inside ±5. (Hint: what happens to the mean if all values are accidentally divided by 100?)
Solution
suite.add_expectation(gx.expectations.ExpectColumnStdevToBeBetween(
column="balance_pct_gdp", min_value=0.2, max_value=3,
meta={"dimension": "plausibility", "note": "catches /100 or ×100 unit errors"}))
If every value were divided by 100, individual values would still lie inside ±5 and the mean would move only slightly towards zero, but the standard deviation would fall from about 0.6 to about 0.006, far below 0.2. Aggregate expectations catch errors that row-level ranges can't.
42.5 A File-Based Data Context: the GX Project (23 min)
An ephemeral context forgets everything. For real work, GX stores its configuration and objects on disk, in a folder you commit to Git, just like the dbt project.
# quality/day42_init_project.py
import great_expectations as gx
context = gx.get_context(mode="file", project_root_dir="quality")
print(type(context).__name__)
print("Project root:", context.root_directory)
python quality/day42_init_project.py
find quality/gx -maxdepth 2 | sort
GX created quality/gx/:
| Path | Contains | Commit? |
|---|---|---|
great_expectations.yml | project configuration (data sources, stores) | ✓ |
expectations/ | expectation suites, saved as JSON | ✓ |
validation_definitions/ | saved validation definitions | ✓ |
checkpoints/ | saved checkpoints | ✓ |
plugins/ | custom code (advanced) | ✓ |
uncommitted/ | local only: config variables (secrets), Data Docs site, validation results | ✗ |
GX writes its own .gitignore inside gx/ to exclude uncommitted/. Confirm with:
cat quality/gx/.gitignore
git status quality/
Running gx.get_context(mode="file", project_root_dir="quality") a second time loads the existing project instead of creating a new one, so every later script starts with this same line.
A helper module for the project
Create quality/gx_helpers.py. Every script in quality/ will import it:
"""Shared helpers for the macro GX project."""
from pathlib import Path
import great_expectations as gx
PROJECT_ROOT = Path(__file__).resolve().parent # .../quality
def get_context():
"""Load (or create) the file-based Data Context in quality/gx."""
return gx.get_context(mode="file", project_root_dir=str(PROJECT_ROOT))
def get_or_add_suite(context, name):
"""Return the named suite, creating it if it doesn't exist yet."""
try:
return context.suites.get(name)
except Exception:
return context.suites.add(gx.ExpectationSuite(name=name))
Path(__file__).resolve().parent is the folder containing this file, so the helper works no matter which directory you run scripts from.
42.6 Mini-Project: Validating the BoC Decision Log (23 min)
The decision log (macro_dbt/seeds/seed_boc_decisions.csv) is hand-maintained, the kind of data most prone to typos. Write quality/day42_decisions.py that:
- Loads the file context with
gx_helpers.get_context(). - Reads the CSV with pandas (
parse_dates=["decision_date"]). - Registers a pandas data source
seeds_pandas, assetboc_decisions, and a whole-dataframe batch definition (reuse them if they already exist). - Builds and saves a suite
boc_decisions_suitewith at least six expectations:decision_datenot null and unique;ratebetween 0 and 10;change_bpin the set of multiples of 25 from −100 to +100;- at least 25 rows;
- columns exactly
decision_date, rate, change_bp, note; - (stretch)
ratenever negative andchange_bpconsistent with the previous rate. Try it and discover why that isn't a single-row expectation. (Answer: it needs row ordering, so it belongs in a SQL-backed check or in dbt, as on Day 36.)
- Validates, prints each expectation's result, and exits with code 1 if the suite fails.
Solution
"""Validate the hand-maintained BoC decision log seed."""
import sys
import great_expectations as gx
import pandas as pd
from gx_helpers import PROJECT_ROOT, get_context, get_or_add_suite
CSV = PROJECT_ROOT.parent / "macro_dbt" / "seeds" / "seed_boc_decisions.csv"
context = get_context()
df = pd.read_csv(CSV, parse_dates=["decision_date"])
# Data source → asset → batch definition (get or add, so the script can re-run)
try:
source = context.data_sources.get("seeds_pandas")
except Exception:
source = context.data_sources.add_pandas(name="seeds_pandas")
try:
asset = source.get_asset("boc_decisions")
except Exception:
asset = source.add_dataframe_asset(name="boc_decisions")
try:
batch_def = asset.get_batch_definition("whole")
except Exception:
batch_def = asset.add_batch_definition_whole_dataframe(name="whole")
batch = batch_def.get_batch(batch_parameters={"dataframe": df})
suite = get_or_add_suite(context, "boc_decisions_suite")
E = gx.expectations
for exp in [
E.ExpectColumnValuesToNotBeNull(column="decision_date"),
E.ExpectColumnValuesToBeUnique(column="decision_date"),
E.ExpectColumnValuesToBeBetween(column="rate", min_value=0, max_value=10),
E.ExpectColumnValuesToBeInSet(column="change_bp",
value_set=list(range(-100, 101, 25))),
E.ExpectTableRowCountToBeBetween(min_value=25),
E.ExpectTableColumnsToMatchSet(column_set=["decision_date", "rate", "change_bp", "note"],
exact_match=True),
]:
suite.add_expectation(exp)
suite.save()
results = batch.validate(suite)
for r in results.results:
print(f"{'✓' if r.success else '✗'} {r.expectation_config.type} "
f"{r.expectation_config.kwargs.get('column', '')}")
print("Suite success:", results.success)
sys.exit(0 if results.success else 1)
Run it twice. The second run reuses the data source and suite. Look at quality/gx/expectations/boc_decisions_suite.json: your suite is now a versionable file. Note that re-adding the same expectation on each run can create duplicates in some versions; if you see that, build the suite once in a setup script (as on Day 43) and only validate in the check script.
Then introduce a typo in the CSV (e.g. change_bp of -30), re-run, read the output, and restore the file.
Day 42 checklist
- GX runs in its own environment, and I know why separate environments prevent conflicts
- I can explain Data Context, Data Source, Data Asset, Batch Definition, Batch, Expectation, Suite
- I can validate an expectation in an ephemeral context and read the detailed result
- I know the expectation families (table, column map, aggregate, multi-column) and
result_format - I created a file-based Data Context and know what to commit and what stays in
uncommitted/ - I validated the BoC decision log with a saved suite and a meaningful exit code
git add quality/ .gitignore
git commit -m "Day 42: GX environment, context, first suites"
git push