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.

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 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.

orders.yaml
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
orders.csv
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
run.py (output below is from a real run, lakelogic 1.88.3)
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

  1. Pick one table with a stable suite. Avoid the one with the most custom Expectations first.
  2. Draft the contract from data. infer_contract("orders.csv", title="Orders") returns a draft with field names and types and, with suggest_rules=True (the default), suggested rules. Call .save() to write YAML. Treat it as a starting point: in our run it inferred order_date as a string from CSV and marked fields required from a five-row sample.
  3. Translate the suite using the table above. Anything without a clean equivalent stays in GX for now.
  4. Run both in parallel on the same batches and compare: rows GX flags in unexpected_index_list against rows in the quarantine frame. Differences usually point at type casting or null handling, which the two tools treat differently.
  5. 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

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

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.