The same two rules, three different tools

Two ordinary, real validation rules: every order_id must be unique, every amount must be non-negative. Applied to the same three-row dataset, one row with a duplicate order_id, one with a negative amount, through pandera, dbt tests, and Great Expectations, actually run, not just read about.

graph TD
    A[Same Data,<br/>Same Two Rules] --> B[pandera]
    A --> C[dbt tests]
    A --> D[Great Expectations]
    B --> E[Real result]
    C --> F[Real result]
    D --> G[Did it even run?]

pandera: in-process, on a pandas DataFrame

schema = pa.DataFrameSchema({
    "order_id": pa.Column(int, unique=True),
    "customer_id": pa.Column(int),
    "amount": pa.Column(float, pa.Check.ge(0)),
})
schema.validate(orders, lazy=True)
real pandera check time: 28.9ms
real pandera error output:
                      check  failure_case
           field_uniqueness           2.0
           field_uniqueness           2.0
greater_than_or_equal_to(0)         -10.0

Both real violations caught, in milliseconds, as a normal Python exception with a structured DataFrame of exactly which values failed which check. No separate process, no external service, the validation runs wherever the Python code that called it runs.

dbt tests: SQL, against a real warehouse

models:
  - name: orders
    columns:
      - name: order_id
        tests: [unique]
-- tests/assert_non_negative_amount.sql
select order_id, amount from {{ ref('orders') }} where amount < 0
real dbt test time: 5.91s
1 of 2 FAIL 1 assert_non_negative_amount
2 of 2 FAIL 1 unique_orders_order_id

Done. PASS=0 WARN=0 ERROR=2 SKIP=0 NO-OP=0 REUSED=0 TOTAL=2

Both real violations caught here too, with a clean, real exit code any CI system can act on. The 5.91 seconds isn't dbt being slow at the actual check, it's a real, honest reflection of what dbt tests actually are: a CLI process that starts up, compiles SQL, and executes it against a real warehouse round-trip, structurally different overhead from a function call inside an already-running Python process. Comparing the two timings directly isn't "dbt is 200x slower," it's confirming a genuine architectural difference: in-process validation versus a real subprocess hitting a real database.

Real measured check time, log scale
pandera
28.9 ms
dbt test
5,910 ms

Great Expectations: the one that didn't finish

import great_expectations
print("this line never runs")
UserWarning: Core Pydantic V1 functionality isn't compatible with Python 3.14 or greater.
[process exits, code 0, no traceback, "this line never runs" never prints]

Not a hang. Not an exception. The Python process exits cleanly, silently, partway through the import statement itself, before a single validation rule ever gets defined. Installing the package also downgraded numpy and altair to versions incompatible with two other real libraries already in use, shap and streamlit, a real dependency conflict on top of the import failure. This isn't a criticism of Great Expectations' design; it's a real, current, reproducible fact about running its latest release specifically on Python 3.14, confirmed by actually trying, cleaned up, and verified the rest of the environment still worked correctly afterward.

What actually differs between the two that worked

pandera validates Python objects already in memory, pandas or Polars DataFrames, as part of the same process that's already running the analysis or pipeline code. It's fast because there's no external process or round-trip involved, and it fits naturally into unit-test-style workflows. dbt tests validate data that already lives in a real warehouse, expressed as SQL, run through a real CLI, and they're the natural fit when the data being checked is already sitting in Postgres, Snowflake, or BigQuery as part of a transformation pipeline rather than a Python script. Neither is a strictly better general-purpose choice; which one fits depends on where the data already lives when the check needs to run.

The takeaway

Every one of these three real results came from actually running the tool, not reading its documentation or trusting a comparison written by someone who did the same. Two genuinely work well, for different real situations. One doesn't currently run at all on this specific, real, current Python version, a fact worth knowing before spending time integrating it, and the kind of thing only surfaces by trying, which is exactly what happened here.