Refactoring a dbt model without a way to prove the output hasn’t changed is guesswork. You can eyeball the SQL, run a few spot checks, and hope for the best. Or you can use dbt-audit-helper to compare your new model against the original, row by row and column by column, and know exactly what changed.
dbt-audit-helper is maintained by dbt Labs, currently at version 0.13.0. It’s Fusion-compatible (require-dbt-version: >=1.2.0, <3.0.0), depends on dbt-utils, and supports Snowflake, BigQuery, Postgres, and Redshift. The hash-based quick macros are limited to Snowflake and BigQuery.
The progressive validation workflow
Audit-helper works best when you start broad and narrow down. Don’t jump straight to row-level comparison on a 100-million-row table. Instead, follow this sequence:
- Schema check with
compare_relation_columns(are the columns the same?) - Row count check with
compare_row_counts(same number of rows?) - Quick hash check with
quick_are_relations_identical(fast pass/fail, Snowflake/BigQuery only) - Row-level comparison with
compare_relationsorcompare_queries(which rows differ?) - Identify problem columns with
compare_which_relation_columns_differ(where are the differences?) - Column deep-dive with
compare_column_values(what exactly changed in a specific column?) - Full column audit with
compare_all_columns(per-column summary across all columns) - Row classification with
compare_and_classify_relation_rows(is each row identical, modified, added, or removed?)
Each step is cheaper than the next. If schema or row count already shows a mismatch, there’s no point running a full row comparison. This saves both time and warehouse compute.
Macro reference
compare_relation_columns
Compares schema metadata between two relations: column names, ordinal positions, and data types. This catches missing columns, type changes, and reordering before you spend cycles on data comparison.
{{ audit_helper.compare_relation_columns( a_relation=ref('mrt__finance__revenue'), b_relation=api.Relation.create( database='analytics_db', schema='production', identifier='mrt__finance__revenue' )) }}Run this first. If the schemas don’t match, you’ll need to resolve that before row-level comparisons make sense.
compare_row_counts
A simple row count comparison between two relations. Added in v0.11.0 (March 2024).
{{ audit_helper.compare_row_counts( a_relation=ref('mrt__finance__revenue'), b_relation=api.Relation.create( database='analytics_db', schema='production', identifier='mrt__finance__revenue' )) }}If counts match, move on to data comparison. If they don’t, you have either a filtering issue or a join that’s fanning out rows.
quick_are_relations_identical
Uses hash comparison for a fast pass/fail. Snowflake and BigQuery only.
{{ audit_helper.quick_are_relations_identical( a_relation=ref('mrt__finance__revenue'), b_relation=api.Relation.create( database='analytics_db', schema='production', identifier='mrt__finance__revenue' )) }}If it passes, you’re done. No need for deeper comparison. If it fails, move to compare_relations to find out why.
There’s also a query-based variant: quick_are_queries_identical.
compare_relations
The core macro. Performs row-by-row validation using a UNION-based approach.
{{ audit_helper.compare_relations( a_relation=ref('mrt__finance__revenue'), b_relation=api.Relation.create( database='analytics_db', schema='production', identifier='mrt__finance__revenue' ), exclude_columns=["_fivetran_synced"], primary_key="revenue__id", summarize=true) }}With summarize=true (the default), the output shows counts and percentages:
- True/True: rows present in both relations (matches)
- True/False: rows only in A
- False/True: rows only in B
Set summarize=false to see the actual row data with in_a/in_b flags, which helps identify the specific rows that differ.
compare_queries works identically but accepts raw SQL SELECT statements instead of relations. Use it when you need to filter, rename, or recast columns before comparison.
compare_which_relation_columns_differ
When compare_relations shows mismatches but you don’t know which columns are causing them, this macro identifies the culprits. Added in v0.12.0 (June 2024).
{{ audit_helper.compare_which_relation_columns_differ( a_relation=ref('mrt__finance__revenue'), b_relation=api.Relation.create( database='analytics_db', schema='production', identifier='mrt__finance__revenue' ), primary_key_columns=['revenue__id']) }}Run this before compare_column_values to avoid manually checking columns one by one.
compare_column_values
Deep-dives into a single column’s values across two queries joined on a primary key.
{{ audit_helper.compare_column_values( a_query="SELECT revenue__id, revenue__amount FROM " ~ ref('mrt__finance__revenue'), b_query="SELECT revenue__id, revenue__amount FROM analytics_db.production.mrt__finance__revenue", primary_key="revenue__id", column_to_compare="revenue__amount") }}Output categorizes each row as: perfect match, both null, missing from A, missing from B, null in A only, null in B only, or values do not match.
Gotcha: the .print_table() method is not compatible with dbt Cloud. Use a log() workaround to display results in the Cloud IDE.
compare_all_columns
Compares all columns across two relations, column by column. The summary output shows per-column counts of perfect_match, null_in_a, null_in_b, missing_from_a, missing_from_b, and conflicting_values.
This is the macro built for CI. Create a test that filters on conflicts:
models: - name: mrt__finance__revenue data_tests: - dbt_utils.expression_is_true: expression: "conflicting_values = 0" where: "1=1"Or use --store-failures to persist mismatches for investigation.
compare_and_classify_query_results
Added in v0.12.0 and marked “v0, subject to change.” Classifies each row as identical, modified, added, or removed.
{{ audit_helper.compare_and_classify_relation_rows( a_relation=ref('mrt__finance__revenue'), b_relation=api.Relation.create( database='analytics_db', schema='production', identifier='mrt__finance__revenue' ), primary_key_columns=['revenue__id'], columns=['revenue__amount', 'revenue__currency', 'revenue__booked_at'], sample_limit=20) }}The sample_limit parameter (default 20) caps the number of sample rows returned per classification. The relation wrapper is compare_and_classify_relation_rows.
Real-world workflows
SQL-to-dbt migration
When migrating legacy SQL to dbt, the question is always “does my new dbt model produce the same output as the old query?” Load your existing tables into the same warehouse as your dbt project, build the equivalent dbt models, then compare them systematically.
The workflow I recommend:
- Start with
compare_relation_columnsto verify the schemas match. Column names, types, and order should be identical. Fix any discrepancies here before moving forward. - Run
compare_row_countsas a quick sanity check. Mismatched counts usually point to filtering differences or JOIN fan-outs. - Run
compare_relationsfor a high-level match percentage. A 100% match means you’re done. Anything below means digging deeper. - If below 100%, use
compare_which_relation_columns_differto isolate problem columns. This saves you from checking every column manually. - Use
compare_column_valuesto investigate each problematic column and understand the exact nature of the mismatches.
A practical tip from the Indicium Tech team: compare 5 columns at a time, confirm they match, then expand. This keeps the feedback loop tight and prevents you from chasing too many issues at once. Trying to debug all mismatches simultaneously usually leads to confusion about which change fixed what.
Refactoring validation
Refactoring is where audit-helper gets the most use day-to-day. You’re rewriting a CTE, changing a join strategy, or restructuring a model, and you need to verify that the output stays the same. Use compare_all_columns referencing your dev model against production:
{{ audit_helper.compare_all_columns( a_relation=ref('mrt__finance__revenue'), b_relation=api.Relation.create( database='analytics_db', schema='production', identifier='mrt__finance__revenue' ), primary_key_columns=['revenue__id']) }}In this setup, ref('mrt__finance__revenue') resolves to your dev schema while api.Relation.create points directly at production. Any differences surface immediately.
CI/CD integration
In dbt Cloud: include compare_all_columns tests in CI jobs triggered on pull requests. The ref() compiles to the PR schema automatically, so dev-vs-prod comparison happens on every PR.
In dbt Core with GitHub Actions: build modified models with --defer --state to target only changed models, then run audit tests with --store-failures to persist any mismatches for review.
For interactive investigation, Hex offers a notebook template that pairs well with audit-helper for visual PR validation workflows.
Large-scale ETL migrations
For enterprise-scale migrations (like Informatica to dbt), the dbt-audit-helper-ext package by Infinite Lambda extends audit-helper with a validation log table, summary view, codegen scripts for auto-generating validation macros, and threshold-based acceptance: 100% match = pass, 99%+ = warning, below 99% = fail. It’s available on the dbt package Hub as infinitelambda/audit_helper_ext (v0.8.0).
Limitations and gotchas
Primary key requirement. Most macros need a unique, non-null primary key to join the two relations. Make sure your models pass unique and not_null tests before running audit-helper comparisons. Without a reliable key, join-based macros produce misleading results.
Floating point precision. All comparisons use exact equality. There’s no epsilon tolerance for floating point values. If you’re comparing calculated fields with decimals, cast to a specific precision first:
{{ audit_helper.compare_queries( a_query="SELECT revenue__id, ROUND(revenue__amount, 2) AS revenue__amount FROM " ~ ref('mrt__finance__revenue'), b_query="SELECT revenue__id, ROUND(revenue__amount, 2) AS revenue__amount FROM analytics_db.production.mrt__finance__revenue", primary_key="revenue__id") }}Performance on large tables. The macros use FULL OUTER JOIN and UNION ALL under the hood, which means full table scans. On large tables, this gets expensive. Use quick_are_relations_identical for an initial fast check, filter with WHERE clauses to reduce scope, or compare a date-bounded subset before running a full comparison.
One model at a time. Unlike tools like Datafold that automatically discover and compare all affected models, audit-helper requires manual setup per model. This is fine for targeted validation but tedious for migrations spanning dozens of models.
dbt Cloud compatibility. The .print_table() method doesn’t work in dbt Cloud’s IDE. Use {{ log(result, info=true) }} as a workaround.
When audit-helper isn’t enough
audit-helper excels at point-in-time comparison: “does my new model produce the same output as the old one?” For ongoing monitoring, you need different tools.
Elementary handles data observability: anomaly detection, schema change tracking, and volume monitoring over time. It’s complementary to audit-helper, not a replacement.
dbt-expectations provides 40+ generic tests for value ranges, patterns, and statistical checks. Use it for ongoing quality validation rather than one-time comparisons.
Datafold Cloud is the commercial alternative. The open-source data-diff is no longer actively maintained as of May 2024. Datafold’s strength is automatic model discovery across your entire DAG, which saves significant setup time on large migrations. If you’re migrating dozens of models and don’t want to manually set up audit-helper for each one, Datafold is worth evaluating.
Soda takes a YAML-based approach to data quality checks with broader scope than table comparison. It can serve as a complement to audit-helper for teams that want ongoing validation beyond migration windows.
For most teams, audit-helper covers the validation workflow well. It’s free, maintained by dbt Labs, and sits naturally inside your existing dbt project. The progressive approach (schema, then counts, then rows, then columns) catches issues early without burning through warehouse credits on unnecessary full-table comparisons.