An upstream team adds exchanged to the order status values. Your Expectation Suite
for orders_bronze still lists the old set, so the next Checkpoint fails. You update
the suite. Then you remember the PySpark job that filters statuses, the fixture file in CI, and the
column description in the catalogue. Each one holds its own copy of the same fact.
This happens because a suite describes what the data should look like, but other parts of the pipeline (the schema it is cast to, the test data, the code that routes bad rows) each keep their own description. Nothing forces them to agree, so a change lands in one place and drifts in the others.
This guide is for teams already running Great Expectations (GX) who are considering data contracts. It covers what GX does well, the friction points that tend to prompt the question, a side-by-side mapping of common Expectations to a contract, a migration path, and the cases where staying on GX is the better choice. It does not claim GX is broken; for many teams it is the right tool.
What Great Expectations does well
GX Core is a mature Python library with a clear model. In the current documentation, a Data Source and Data Asset describe where the data lives, an Expectation Suite groups assertions, a Validation Definition links a suite to a batch, and a Checkpoint runs validations in production and triggers Actions such as Slack or email alerts. Data Docs render suites and results as human-readable pages.
- A large Expectation library covering nulls, sets, ranges, regex, uniqueness, row counts and distributions.
- Broad data access: pandas and Spark DataFrames, files, and SQL databases (connect to data).
- Row-level detail when you ask for it. With
result_formatset toCOMPLETE, row-level Expectations returnunexpected_index_list, up to 200 unexpected values, and a query to fetch the rest (result formats). - Reporting and alerting built in through Data Docs and Actions.
An earlier version of this article said GX only reports batch pass or fail. That was wrong: GX can identify failing rows. What it does not do is split the batch for you. It reports; your pipeline decides what to do with the rows.
Where teams feel friction
These are the questions that tend to come up, described as mechanisms rather than as a verdict on the tool:
- The suite is one of several descriptions. Column types live in the table or cast step, quarantine logic lives in pipeline code, test fixtures live in CI. A change to one does not update the others.
- Validation and routing are separate steps. Turning
unexpected_index_listinto a quarantine table with a reason per row is code you write and maintain. - Engine coverage. The documented DataFrame sources are pandas and Spark. A team standardising on Polars or DuckDB for local runs converts frames or goes through SQL.
- Configuration surface. Data Sources, Assets, Batch Definitions, suites, Validation Definitions and Checkpoints are each a concept to learn and keep in sync. That structure pays off at scale; for a handful of tables it can feel heavy.
The alternative: one contract per dataset
A data contract puts the schema, the quality rules and the handling of failing rows in one file
per dataset, and the runtime reads that file. In LakeLogic (open source, Apache 2.0) a run returns two frames:
rows that passed, and rows that failed with a _lakelogic_errors column naming each
broken rule. The same contract runs on Polars, DuckDB, Spark, Snowflake or BigQuery; Polars and
DuckDB come with the base install.
Side by side: common Expectations and their contract form
| GX Expectation | Contract equivalent | Scope |
|---|---|---|
expect_column_to_exist |
Field listed in model.fields |
Schema |
expect_column_values_to_not_be_null |
required: true |
Per row |
expect_column_values_to_be_between |
min / max on the field |
Per row |
expect_column_values_to_be_in_set |
accepted_values on the field |
Per row |
expect_column_values_to_match_regex |
regex_match row rule |
Per row |
expect_column_values_to_be_unique |
unique dataset rule |
Whole batch |
expect_table_row_count_to_be_between |
row_count_between dataset rule |
Whole batch |
| Custom or distribution Expectations | SQL row rule, or keep in GX | Case by case |
One difference matters: in a contract, a failed row rule moves that row to the quarantine frame, while
a dataset rule (uniqueness, row count) is evaluated over the whole batch and reported as a pass or
fail for the run. In our test, a duplicate order_id failed the unique rule
but both rows stayed in the passing frame. Plan for that if you rely on per-row handling of
duplicates.
A worked example
Here is a small orders suite (statuses, a non-negative amount, an ID format, uniqueness) expressed as a contract. The data is five made-up order rows.
version: 1.0.0 info: title: Orders model: fields: - name: order_id type: string required: true - name: customer_id type: string required: true - name: amount type: float min: 0 - name: status type: string accepted_values: [pending, shipped, delivered, returned] - name: order_date type: date quality: row_rules: - regex_match: field: order_id pattern: "^ORD-[0-9]{4}$" dataset_rules: - unique: order_id
order_id,customer_id,amount,status,order_date ORD-1001,C-01,42.50,shipped,2026-10-01 ORD-1002,C-02,-5.00,delivered,2026-10-01 ORD-1003,C-03,18.00,exchanged,2026-10-02 ,C-04,30.00,pending,2026-10-02 ORD-1005,C-05,12.00,pending,2026-10-03
from lakelogic import DataProcessor result = DataProcessor("orders.yaml").run_source("orders.csv") print(result.good_count, result.bad_count) for row in result.bad.select(["order_id", "_lakelogic_errors"]).to_dicts(): print(row) # 2 3 # {'order_id': 'ORD-1002', '_lakelogic_errors': ['Rule failed: amount_range ("amount" >= 0.0)']} # {'order_id': 'ORD-1003', '_lakelogic_errors': ['Rule failed: status_accepted_values ("status" IN (...))']} # {'order_id': None, '_lakelogic_errors': ['Rule failed: order_id_required ("order_id" IS NOT NULL)', # 'Rule failed: order_id_regex_match (regexp_like(...))']}
The status list in the second error is shortened here for width. The row with a missing ID failed
two rules, and both are recorded. The same file runs on DuckDB by passing
engine="duckdb" to DataProcessor; in our run it produced the same 2 and 3
split.
A migration path, one table at a time
- Pick one table with a stable suite. Avoid the one with the most custom Expectations first.
- Draft the contract from data.
infer_contract("orders.csv", title="Orders")returns a draft with field names and types and, withsuggest_rules=True(the default), suggested rules. Call.save()to write YAML. Treat it as a starting point: in our run it inferredorder_dateas a string from CSV and marked fields required from a five-row sample. - Translate the suite using the table above. Anything without a clean equivalent stays in GX for now.
- Run both in parallel on the same batches and compare: rows GX flags in
unexpected_index_listagainst rows in the quarantine frame. Differences usually point at type casting or null handling, which the two tools treat differently. - Switch the routing to the contract's good and bad frames, then retire that suite. Parallel running costs compute, so set an end date.
LakeLogic can also generate synthetic rows from a contract
(DataGenerator("orders.yaml", seed=42).generate(rows=1000)), which keeps CI fixtures tied
to the same field definitions. Validate generated data against the contract before relying on it:
in our run, some generated rows still failed the min: 0 rule.
When to stay on Great Expectations
- You use GX mainly for profiling, reporting and Data Docs, and your pipelines already handle failing rows the way you want.
- Your suites lean on distribution and statistical Expectations that have no direct contract equivalent.
- You are on pandas. LakeLogic has no pandas engine; GX supports pandas directly.
- Your team is invested in Checkpoints and Actions and the friction above is not costing you anything.
A contract is worth it when the same rule exists in several places and keeps drifting, or when you want rows split and labelled by the runtime instead of by code you maintain.
Limits of this comparison
The mapping covers common Expectations, not the full GX library. GX facts are from the GX Core documentation (version 1.x) and may change. Neither tool decides what your business rules should be: a contract records rules someone has agreed, and a passing run shows the data met those rules, not that the numbers are correct.
Key takeaways
- GX can identify failing rows with
result_format: COMPLETE; routing them is up to you. - Most column-level Expectations map one to one onto contract fields and shorthands.
- In a contract, row rules quarantine rows; dataset rules report on the batch.
- Migrate table by table, run both in parallel, and compare flagged rows before switching.
- Stay on GX for pandas workloads, heavy statistical suites, or when it already fits.
For the difference between a schema check and a full contract, see Data contracts vs schema validation.
Try one table in your browser
The quickstart notebook writes a contract, runs it on Polars and shows the quarantined rows with their reasons. No install needed.