For the complete documentation index, see llms.txt. This page is also available as Markdown.

Reconciliation checks

This page documents the contract language syntax for reconciliation.

Reconciliation checks validate that a target dataset matches one or more source datasets, ensuring that data remains consistent after migrations, in pipelines, or during synchronizations. They can be used for both metric-level (aggregate) validation and row-level (record-by-record) validation.

Learn more about the use cases and performance considerations in the Data reconciliation page.

Prerequisites

To use reconciliation checks, you must either:

  • Run your contract with Soda Runner, which has reconciliation support built in, or

  • Install the Soda Reconciliation extension locally:

pip install "soda-reconciliation" --pre -i "https://${SODA_API_KEY_ID}:${SODA_API_KEY_SECRET}@enterprise.pypi.cloud.soda.io" --extra-index-url=https://pypi.cloud.soda.io

Follow the private PyPi installation flow to set up your environment and install the necessary Soda extensions.

Example

Structure

  • dataset: defines the target dataset.

  • reconciliation.sources: defines the source datasets to compare against. Each source has a name and a dataset.

  • reconciliation.sources[].filter: is an optional filter applied only to that source dataset.

  • Each reconciliation check specifies which source it targets via the source: field.

  • Each reconciliation check supports an additional check-level filter applied consistently to both source and target, layered on top of dataset-level filters. Every check type except schema diff can instead filter each side independently with source_filter: and target_filter:. See Filters.

  • Checks that name columns can transform them before comparing, per side: keyed source_column_expressions:/target_column_expressions: maps on the row-level checks (rows_diff, reference_diff), singular source_column_expression:/target_column_expression: strings on aggregate_diff and freshness_diff. duplicate_diff takes no expressions. See Transform values before comparing.

  • Thresholds define acceptable differences.

All Common Check Configurations (filters, thresholds, names, qualifiers, attributes) apply to reconciliation checks. Learn more about Common Check Configurations.

Source configuration

Each entry in the sources: list configures a source dataset to compare against the target.

Key
Required
Description

name

Yes

Identifier for this source. Used in the source: field on checks. Must be unique across sources.

dataset

Yes

DQN path to the source dataset (e.g., data_source/db/schema/table). Several sources may reference the same dataset, each with its own filter.

filter

No

SQL filter expression applied only to this source dataset.

default

No

Set to true to mark this as the unnamed default source. Used only when migrating from single-source. Cannot be combined with name.

Targeting checks to a source

Every check should specify which source it validates against using the source: field:

If a default (unnamed) source exists, checks without source: run against it. If no default source exists, every check must have an explicit source:.

Validation rules

  • All sources must have a name (unless using default: true for migration)

  • default: true cannot be combined with name

  • Source names must be unique

  • If no default source exists, every check must specify source:

  • Source names are trimmed of whitespace; empty or whitespace-only names are rejected


Filters

Reconciliation compares two datasets that usually live in two different systems, so a filter always applies to a side — source or target — never to "the comparison" as a whole. Filters can be declared at three levels, and every filter that applies to the same side is combined with AND.

Filter application matrix

Where it is declared
Key
Applies to

Contract root

filter

The target dataset, for every check in the contract (reconciliation checks included). See Configure a filter for all checks.

reconciliation.sources[]

filter

The source dataset of that entry, for every check bound to that source.

Check

filter

Both sides of that check.

Check

source_filter

The source side of that check only.

Check

target_filter

The target side of that check only.

The effective filter for a side is the AND of every level that applies to it. For a check bound to a source that declares its own filter, the source side is evaluated as sources[].filter AND source_filter.

Every reconciliation check type accepts both the both-sides filter and the split form, with two exceptions: schema_diff compares column metadata, not rows, and rejects all filter keys; and a metric_diff in query mode rejects them too, because the query embeds its own conditions. Declaring filter together with a split key on one check is an error.

In the past, the check-level filter on reference_diff was accepted but never applied. Now, it is applied to both sides, like it is on every other reconciliation check type. If you declared a filter on a reference_diff check, the compared row set changes: verify that the filter still expresses what you intend on both sides, or split it into source_filter and target_filter.

Filter both sides with the same condition

Use the check-level filter when the same SQL condition is valid on both sides — which requires the column names and semantics to be identical in the source and the target dataset.

Filter each side independently

When the two datasets do not share column names, encodings, or partitioning conventions — a common situation when reconciling a legacy system against its migrated copy — use source_filter and target_filter. Both keys are optional and independent: you can set one, the other, or both. They are available on every check type except schema_diff (and a metric_diff in query mode).

Rules

  • The legacy filter key remains supported and unchanged. It is the both-sides shorthand.

  • filter and the split form are mutually exclusive. Declaring filter together with source_filter and/or target_filter on the same check is a parse error.

  • A filter and a custom SQL query on the same side of the same check is a parse error — put the condition in the query instead. source_query with target_filter is valid.

  • The legacy filter combined with source_query or target_query is a parse error, because its both-sides promise cannot be kept. Use source_filter/target_filter instead.

  • A filter and a per-column expression on the same side compose freely: the filter decides which rows are read, the expression transforms the values that are compared.

Filters are not part of a check's identity. Rewriting filter: X into source_filter: X plus target_filter: X keeps the same check in Soda Cloud, with its history intact.

Choosing between filters, expressions, and custom queries

Three groups of keys shape what a row-level check actually compares. They form a ladder — take the lowest rung that does the job:

  1. Filters (filter, source_filter, target_filter) choose which rows are compared. Start here: they are the cheapest, they run as a WHERE clause on the side they apply to, and they are available on every reconciliation check type except schema_diff, which compares schemas rather than rows and accepts no row-selection keys.

  2. Per-column expressions (source_column_expressions, target_column_expressions) change what a value looks like when it is compared — trim whitespace, rescale a number, cast a type — one column at a time, without touching the rest of the check. Use them when the right rows are being read but one column's shape differs between the two systems. The keyed maps exist on the row-level checks (rows_diff, reference_diff); aggregate_diff and freshness_diff name one column per side and take the singular source_column_expression / target_column_expression strings, the same shape as the column-level column_expression. duplicate_diff takes no expressions, matching the non-reconciliation multi-column duplicate; row_count_diff names no columns; metric_diff is already a full expression per side.

  3. Custom SQL queries (source_query, target_query) replace what is read on a side. Use them when the thing to compare is not the dataset at all — a join, a grouped aggregate, a projection that does not exist as a column. They hand you the SQL and, with it, the guarantees Soda otherwise keeps; a view is usually the better answer. rows_diff only.

The rungs compose downwards but not sideways. A filter and an expression can apply to the same side, and each side picks its own rung — source_query with target_filter is fine. A query, however, replaces both of the rungs below it on its own side: that side rejects a filter and rejects an expression map, because either can be written into the SQL you already control.


Row count diff

Compares the row count of the source and target datasets.

Example

Configuration keys

Key
Optional
Description

source

No

Name of the source to validate against

name

Yes

Check names

threshold

Yes

Thresholds

Acceptable difference between source and target. By default, threshold = 0

filter

Yes

Configure a check filter, applied to both source and target.

Cannot be combined with source_filter or target_filter. See Filters.

source_filter

Yes

Filter applied to the source side only.

Combined with sources[].filter using AND. Cannot be combined with filter. See Filters.

target_filter

Yes

Filter applied to the target side only.

Cannot be combined with filter. See Filters.

qualifier

Yes

Check qualifiers

attributes

Yes

Check attributes


Aggregate diff

Compares the result of an aggregate function on a column between source and target.

Example

Configuration keys

Key
Optional
Description

source

No

Name of the source to validate against

function

No

Aggregate function (avg, sum, min, max, avg_length, etc.)

source_column

No

Column to aggregate on the source side

target_column

No

Column to aggregate on the target side

source_column_expression

Yes

SQL expression applied to source_column before it is aggregated, evaluated on the source side. See Transform values before comparing.

target_column_expression

Yes

SQL expression applied to target_column before it is aggregated, evaluated on the target side.

threshold

Yes

Thresholds

Acceptable difference between source and target. By default, threshold = 0

filter

Yes

Configure a check filter

Filter applied to both source and target. Cannot be combined with source_filter or target_filter. See Filters.

source_filter

Yes

Filter applied to the source side only.

Combined with sources[].filter using AND. Cannot be combined with filter. See Filters.

target_filter

Yes

Filter applied to the target side only.

Cannot be combined with filter. See Filters.

name

Yes

Check names

qualifier

Yes

Check qualifiers

attributes

Yes

Check attributes


Duplicate diff

Compares the number or percentage of duplicate rows based on one or more columns.

Example

Configuration keys

Key
Optional
Description

source

No

Name of the source to validate against

source_columns

No

List of column(s) to evaluate duplicates on, on the source side

target_columns

No

List of column(s) to evaluate duplicates on, on the target side, matched to source_columns by position

threshold

Yes

Thresholds

Acceptable difference between source and target. By default, threshold = 0

filter

Yes

Configure a check filter

Filter applied to both source and target. Cannot be combined with source_filter or target_filter. See Filters.

source_filter

Yes

Filter applied to the source side only.

Combined with sources[].filter using AND. Cannot be combined with filter. See Filters.

target_filter

Yes

Filter applied to the target side only.

Cannot be combined with filter. See Filters.

name

Yes

Check names

qualifier

Yes

Check qualifiers

attributes

Yes

Check attributes


Freshness diff

Compares freshness (recency of the latest timestamp) between source and target.

Example

Configuration keys

Key
Optional
Description

source

No

Name of the source to validate against

source_column

No

Timestamp column used to measure freshness on the source side

target_column

No

Timestamp column used to measure freshness on the target side

source_column_expression

Yes

SQL expression applied to source_column before its maximum is read, evaluated on the source side. See Transform values before comparing.

target_column_expression

Yes

SQL expression applied to target_column before its maximum is read, evaluated on the target side.

unit

Yes

Unit of time (hour, minute, day)

threshold

Yes

Thresholds

Acceptable difference between source and target. By default, threshold = 0

filter

Yes

Configure a check filter

Filter applied to both source and target. Cannot be combined with source_filter or target_filter. See Filters.

source_filter

Yes

Filter applied to the source side only.

Combined with sources[].filter using AND. Cannot be combined with filter. See Filters.

target_filter

Yes

Filter applied to the target side only.

Cannot be combined with filter. See Filters.

name

Yes

Check names

qualifier

Yes

Check qualifiers

attributes

Yes

Check attributes


Metric diff

Compares results of custom SQL expressions or queries across source and target.

Example

Configuration keys

Key
Optional
Description

source

No

Name of the source to validate against

source_expression

No*

SQL expression for source

target_expression

No*

SQL expression for target

source_query

No*

Full SQL query for source metric

target_query

No*

Full SQL query for target metric

threshold

Yes

Thresholds

Acceptable difference between source and target. By default, threshold = 0

filter

Yes

Configure a check filter

Filter applied to both source and target. Expression mode only. Cannot be combined with source_filter, target_filter, source_query or target_query — in query mode, put the condition in the query. See Filters.

source_filter

Yes

Filter applied to the source side only. Expression mode only.

Combined with sources[].filter using AND. Cannot be combined with filter or source_query. See Filters.

target_filter

Yes

Filter applied to the target side only. Expression mode only.

Cannot be combined with filter or target_query. See Filters.

name

Yes

Check names

qualifier

Yes

Check qualifiers

attributes

Yes

Check attributes

* Either expression or query must be defined.

Compare the result of a full SQL query

source_expression and target_expression are SQL fragments that Soda wraps in a query against the bound datasets. When the value you want to compare cannot be expressed that way — because it needs a join, a subquery, a GROUP BY, or a different table altogether — use source_query and target_query instead. Each query must return a single row with a single value.

Unlike the row-level custom queries on rows_diff, metric_diff queries are both-or-neither: define source_query and target_query together, or neither. They are executed once per verification and take no {{pagination}} marker.

Use metric_diff queries for aggregate-level comparisons over derived data, and rows_diff custom queries when you need to know which records differ.


Rows diff

Compares rows between source and target based on keys, and checks specified columns for differences.

Example

Configuration keys

Key
Optional
Description

source

No

Name of the source to validate against

source_key_columns

No

Key column(s) to align rows in source dataset

target_key_columns

No

Key column(s) to align rows in target dataset

source_columns

Yes

Columns to compare in the target dataset. If omitted, all columns are compared based on column order. The number of defined source columns must match the number of defined target columns

target_columns

Yes

Columns to compare in the target dataset. If omitted, all columns are compared based on column order. The number of defined target columns must match the number of defined source columns.

source_column_expressions

Yes

Map of column name to SQL expression, applied to the source side before the comparison.

Every key must be a column listed in source_key_columns or source_columns. Cannot be combined with source_query. See Transform values before comparing.

target_column_expressions

Yes

Map of column name to SQL expression, applied to the target side before the comparison.

Every key must be a column listed in target_key_columns or target_columns. Cannot be combined with target_query. See Transform values before comparing.

threshold

Yes

Thresholds

Acceptable difference between source and target.

Thresholds can be defined in two ways:

  • As the count of differing rows between source and target.

  • As the percentage of differing rows, relative to the number of tested rows in the source dataset.

By default, threshold = 0

filter

Yes

Configure a check filter

Filter applied to both source and target. Cannot be combined with source_filter, target_filter, source_query or target_query. See Filters.

source_filter

Yes

Filter applied to the source side only.

Combined with sources[].filter using AND. Cannot be combined with filter or with source_query. See Filters.

target_filter

Yes

Filter applied to the target side only.

Cannot be combined with filter or with target_query. See Filters.

source_query

Yes

Full SQL query that produces the source rows to compare, ending with the {{pagination}} marker. See Compare derived data with custom SQL queries.

target_query

Yes

Full SQL query that produces the target rows to compare, ending with the {{pagination}} marker. See Compare derived data with custom SQL queries.

name

Yes

Check names

qualifier

Yes

Check qualifiers

attributes

Yes

Check attributes

Diagnostics

A rows diff check reports the following diagnostic metrics alongside its outcome.

Diagnostic
Meaning

diff_count / diff_percent

Total number of differing rows, and that number as a percentage of the tested source rows.

changed

Rows present on both sides whose compared columns differ.

exclusive_in_source

Rows whose key exists only in the source.

exclusive_in_target

Rows whose key exists only in the target.

source_check_rows_tested / target_check_rows_tested

Rows considered on each side after all filters are applied.

null_key_rows_source / null_key_rows_target

Rows excluded on each side because a key column was NULL. See NULL values in key columns.

Filter the source and target separately

Use source_filter and target_filter when the same SQL condition is not valid on both sides — for example when the legacy source encodes a status as 'A' and the migrated target spells it out. Both keys are optional and independent, and each is AND-combined with the filter declared on the source it is bound to.

The legacy filter key still applies one condition to both sides and remains supported. It cannot be combined with the split keys. See Filters for the full matrix and rules.

NULL values in key columns

Rows diff aligns the two datasets on their key columns, and a NULL key cannot be aligned with anything. Rows with a NULL in any key column are therefore excluded from the comparison on the side they occur on, and counted in the null_key_rows_source and null_key_rows_target diagnostics. Soda logs a warning during the scan when this happens; the check still evaluates on the remaining rows.

Excluded rows are not counted as differences. If a NULL key on one side means a genuine data problem, cover it with a missing check on the key column rather than relying on the rows diff check to surface it.

Choose key columns that are unique and not nullable. A high null_key_rows_source or null_key_rows_target count means a large part of the dataset was never compared.

Transform values before comparing

The two sides of a reconciliation rarely store the same fact in the same shape. A legacy source pads its text columns with trailing spaces where the migrated target does not; a source records a discount as 95 where the target records 0.95. Neither is a data problem, but a plain comparison reports every one of those rows as changed on a rows diff, and every one of those values as exclusive to its side on a reference diff.

source_column_expressions and target_column_expressions handle this one column at a time. Each is a map of column name to SQL expression, evaluated on that side's connection. Soda projects the expression aliased to the column name, so everything downstream — the comparison, the diagnostics, and the failed-row samples — sees the transformed value.

Both maps are available on the two row-level check types, rows_diff and reference_diff. The single-column check types — aggregate_diff and freshness_diff — take the singular form instead: source_column_expression and target_column_expression, one SQL expression per side with no column key, matching the column-level column_expression. duplicate_diff, row_count_diff, metric_diff and schema_diff take no expressions — a metric_diff side is already a full SQL expression.

Example: normalize whitespace, and rescale a percentage

On the source side Soda projects TRIM(name) AS name and discount_pct / 100 AS discount_pct in place of the stored columns; the target side is untouched. The two maps are independent — transform one side, the other, or both. When the same normalization is needed on both sides, declare it in both maps:

Example: normalize the values a reference diff compares

A reference diff takes the same two maps. It has no key columns, so an expression key must name a column listed in source_columns or target_columns — or in the columns shorthand, which sets both lists at once.

The SELECT DISTINCT that reads each side runs on the expression result, so 'abc ' in the source and 'ABC' in the target are one and the same value — which is the point — and codes that differ only in case or padding stop being reported as exclusive to a side.

What the transformed value reaches

Stage
Value used

Row alignment on the key columns (rows diff)

Expression result. The ORDER BY that pages and merges the side uses the expression, not the stored column.

Column comparison (changed, rows diff)

Expression result on each side.

Distinct-value comparison (reference diff)

Expression result. The SELECT DISTINCT and the ordering that pages each side both run on the expression, so two values the expression makes equal count as one. See Distinct values and NULL handling.

Diagnostics (diff_count, changed, exclusive_in_source, exclusive_in_target)

Expression result.

Failed-row samples in Soda Cloud

Expression result. The stored value is not reported alongside it — what you see is what was compared.

NULL exclusion — the key columns of a rows diff (null_key_rows_source / null_key_rows_target), the compared columns of a reference diff

Expression result. An expression that evaluates to NULL excludes its row exactly like a stored NULL does. See NULL values in key columns and Distinct values and NULL handling.

Row counts (source_check_rows_tested, target_check_rows_tested) and the metric-level check types

Unaffected. Expressions transform values, not row sets — only which rows a filter selects, and the NULL exclusion above, change how many rows are counted. Row count diff, aggregate diff and the other metric-level checks read the stored columns and take no expressions.

Rules

  • Every key of an expression map must be a column that side actually compares: on a rows_diff check, one listed in source_key_columns or source_columns (and target_key_columns or target_columns for the target map); on a reference_diff check, which has no key columns, one listed in source_columns or target_columns. A key that names anything else is a parse error rather than a silently ignored expression.

  • A side that uses column expressions must define its source_columns/target_columns list explicitly — on a reference_diff check, the columns shorthand counts as defining both. When the lists are omitted, Soda compares all columns by position (SELECT *), and an expression cannot be applied to that projection — this combination is a parse error.

  • A blank expression is a parse error.

  • A per-column expression and a custom SQL query on the same side of the same check is a parse error — a query already projects whatever SQL you want, so bake the expression into it. source_column_expressions together with target_query is valid. The rule concerns rows_diff alone: a reference_diff check takes no custom queries, so its expression maps have nothing to conflict with.

  • Both maps are rejected on a schema diff, which compares column metadata rather than values. See Filters and queries do not apply.

  • Expressions and filters compose freely: the filter selects which rows are read, from the stored values; the expression transforms the values that are then compared.

  • An expression is a raw SQL fragment evaluated on that side's connection, in that side's dialect — the same trust model as filters and custom queries.

Cost. The expression is evaluated by the database for every row read. An expression that lands in the ORDER BY — a key column on a rows diff, any compared column on a reference diff — can stop the database from using an index on that column; on a large dataset that, rather than the transformation itself, is what you will feel.

Expressions, like filters and queries, are not part of a check's identity. Adding, editing, or removing one keeps the check's history in Soda Cloud.

Compare derived data with custom SQL queries

By default, Soda generates the paginated SELECT statements it runs against each side. When the data you need to compare is a derivation — a join, a normalization, an aggregation — you can supply the SQL for one or both sides with source_query and target_query.

Example: normalize a source to match the target's shape

Per-side independence. source_query alone, target_query alone, or both are all valid. The side without a query uses the query Soda generates. This differs from metric_diff, where the two query keys are both-or-neither.

The {{pagination}} marker. Row-level reconciliation streams both sides page by page, so Soda must be able to order and slice your query. Each custom query must contain the literal marker {{pagination}} exactly once, in trailing position (only whitespace may follow it). Soda replaces it with ORDER BY <key columns> LIMIT <page size> OFFSET <offset> in the dialect of that side, once per page. A query with no marker, more than one marker, or a marker that is not last is rejected when the contract is parsed.

Requirements. Custom queries hand you the SQL, and with it the responsibility for the properties Soda otherwise guarantees:

Requirement
Why

The query is deterministic

Pages are separate executions. A query whose result set changes between executions produces a meaningless diff.

Key columns are unique and define a total order

They become the ORDER BY that makes paging stable, and the key on which the two sides are aligned.

The projection matches the other side

source_key_columns, target_key_columns, source_columns and target_columns refer to the columns your query projects. The number and order of projected columns must match the other side; Soda validates this on the first page and fails the check with an explicit error rather than reporting a misaligned diff. A key column that is not listed in source_columns/target_columns is appended to the end of the projection Soda generates for that side, which is rarely where a custom query projects it — so list the key columns first, in both *_columns lists and in the SELECT of every custom query.

Data is quiescent during the scan

Soda pages with LIMIT/OFFSET and no snapshot isolation. See Caveats and limitations.

Cost. The query is re-executed for every page, exactly like a non-materialized view. A source-side filter that trims the volume before the join is usually worth more than any query tuning.

Grouped comparisons. To reconcile aggregates row by row rather than as a single metric, write a GROUP BY query on each side and use the group-by columns as the key columns:

Queries, like filters, are not part of a check's identity. Editing a query keeps the check's history in Soda Cloud.

Filters and queries cannot both apply to the same side of the same check — put the condition inside the query. source_query with target_filter is valid. If the bound source declares sources[].filter and the check uses source_query, Soda logs a warning at parse time: that check's source side bypasses the source filter, while other checks on the same source keep using it.

Limitations

  • It is not possible to perform rows diff reconciliation with datasets located in the same Snowflake data source. If your data source is Snowflake, and you wish to reconcile datasets within Snowflake, one of the datasets to reconcile must be:

    • in a different Snowflake instance, or

    • in a different data source type.

  • If the source and target project a different number of columns, the check cannot align them and reports NOT_EVALUATED. Add a schema diff check to the same source to see exactly which columns differ.


Reference diff

Verifies that the values of one or more columns also exist on the other side of the comparison — referential integrity across two data sources. Use it for foreign keys, code tables, and dimension keys that must stay aligned with a system of record that lives in a different database.

Reference diff compares sets of distinct values, not rows: it needs no key columns, and duplicates on either side collapse before the comparison. Where rows diff answers are these two datasets the same, row by row, reference diff answers does every value here have a counterpart there.

Example

Every product_code in the target dataset must also exist in the master product_codes dataset. Codes that exist only in the master dataset are reported, but are not violations in this direction.

Configuration keys

Key
Optional
Description

source

No

Name of the source to validate against

columns

Yes

Shorthand for the columns to compare when both sides name them identically. Equivalent to setting source_columns and target_columns to the same list.

source_columns

Yes

Columns whose values are read on the source side. If omitted, all columns of the source dataset are used, in schema order.

target_columns

Yes

Columns whose values are read on the target side, matched to source_columns by position. If omitted, all columns of the target dataset are used, in schema order. The number of target columns must match the number of source columns.

source_column_expressions

Yes

Map of column name to SQL expression, applied to the source side before the comparison.

Every key must be a column listed in source_columns, or in the columns shorthand; that list must be declared explicitly. See Transform values before comparing.

target_column_expressions

Yes

Map of column name to SQL expression, applied to the target side before the comparison.

Every key must be a column listed in target_columns, or in the columns shorthand; that list must be declared explicitly. See Transform values before comparing.

direction

Yes

Which side must contain the values of the other: target_must_exist_in_source (default), source_must_exist_in_target, or both. See Direction.

threshold

Yes

Thresholds

Acceptable number of values that violate the direction, or that number as a percentage of the tested source rows (metric: percent). By default, threshold = 0

filter

Yes

Configure a check filter

Filter applied to both source and target. Cannot be combined with source_filter or target_filter. See Filters.

source_filter

Yes

Filter applied to the source side only.

Combined with sources[].filter using AND. Cannot be combined with filter. See Filters.

target_filter

Yes

Filter applied to the target side only.

Cannot be combined with filter. See Filters.

page_size

Yes

Number of rows read per page from each side. Default is 100000.

batch_size

Yes

Number of rows fetched per round trip within a page. Default is 1.

name

Yes

Check names

qualifier

Yes

Check qualifiers

attributes

Yes

Check attributes

source_query and target_query are not available on a reference diff check; they are specific to rows diff. That is also why the two expression maps carry no query-exclusivity rule here: on this check type there is no query to bake an expression into.

Diagnostics

A reference diff check reports the following diagnostic metrics alongside its outcome.

Diagnostic
Meaning

diff_count / diff_percent

Number of values that violate the direction, and that number as a percentage of the tested source rows.

exclusive_in_source

Distinct values found on the source side only.

exclusive_in_target

Distinct values found on the target side only.

source_check_rows_tested / target_check_rows_tested

Rows counted on each side after all filters are applied.

source_dataset_rows_tested / target_dataset_rows_tested

Rows in each dataset, before the check's own filters.

diff_percent divides a count of distinct values by a count of rows (source_check_rows_tested), whatever the direction. On a source with many duplicates or many NULLs in the compared columns, the percentage can therefore be larger than it intuitively should be. When the source side has no rows at all, the percentage cannot be computed and Soda reports an error. Prefer metric: count when the two sides differ a lot in cardinality.

Direction

direction decides which side is allowed to hold values that the other one does not have.

Value
The check passes when
Counted as differences

target_must_exist_in_source (default)

Every value in the target also exists in the source.

Values found only in the target (exclusive_in_target).

source_must_exist_in_target

Every value in the source also exists in the target.

Values found only in the source (exclusive_in_source).

both

The two sides hold exactly the same set of values.

Both counts, summed.

Any other value is reported as an error when the contract is parsed.

Both exclusive_in_source and exclusive_in_target are always reported, whatever the direction — only the ones the direction forbids are added to diff_count. A passing check therefore still shows how far the two sides have drifted apart in the tolerated direction.

Distinct values and NULL handling

Both sides are read with a SELECT DISTINCT over the compared columns, ordered by those columns and streamed page by page. Two things follow from that.

  • Duplicates do not matter. A value that occurs a thousand times on one side and once on the other is a match. To compare how often values occur, use duplicate diff, and to compare the rows themselves, rows diff.

  • Rows with a NULL in any compared column are excluded on the side where they occur: Soda adds an IS NOT NULL condition on every compared column to both queries. A NULL references nothing, so it is neither reported as missing on the other side nor counted as a difference.

Filters are applied inside that same SELECT DISTINCT — the WHERE runs before de-duplication — so filtering a side never changes what "distinct" means for it. The source side is evaluated as sources[].filter AND source_filter; see Filters.

A per-column expression does change it: the SELECT DISTINCT, the ordering, and the IS NOT NULL condition all run on the expression result rather than on the stored column. Values the expression makes equal collapse into one, and an expression that evaluates to NULL drops its row exactly like a stored NULL does.

Unlike rows diff, reference diff does not report how many rows the NULL exclusion removed. When a NULL in a compared column is itself a problem, cover it with a missing check on that column.

Compare several columns

Listing more than one column compares the tuple of their values, matched by position: the first source column against the first target column, and so on. The names may differ between the two sides, the number of columns may not.

A check whose two sides list a different number of columns, or whose columns cannot be resolved from the dataset schema, reports NOT_EVALUATED with an explicit error. So does a comparison between values that cannot be ordered against each other — an integer key on one side and a string key on the other, for example. Align the column types when that happens, casting them in a view over one side if the underlying tables cannot be changed.

Reference diff versus the reference check

Soda has two ways to assert referential integrity, and they operate at different scopes.

Check
Compares
Use it to

valid_reference_data (on the invalid check)

A column against a reference dataset in the same data source.

Enforce a foreign key inside one database, as part of the column's validity rules.

reference_diff

The distinct values of one or more columns against a dataset in another data source.

Verify that a copied, migrated, or synchronized dataset still references the same keys as the system of record it came from.

Reference diff reads both sides in pages, exactly like rows diff, so the quiescence caveat applies to it as well.


Schema diff

Compares the actual schema of the source dataset with the actual schema of the target dataset: which columns exist on each side, and — optionally — whether their data types match. The check fails on any difference.

Example

Example with mapping and exclusions

Configuration keys

Key
Optional
Description

source

No

Name of the source to validate against

column_mapping

Yes

Map of source column name to target column name, for columns that were renamed. Mapped pairs are compared as if they had the same name.

ignore_columns

Yes

List of column names to leave out of the comparison entirely — for example technical columns that exist on one side by design.

types

Yes

Whether to compare data types as well as column names. Values are true or false. Default is true; set it to false for a name-only comparison.

name

Yes

Check names

qualifier

Yes

Check qualifiers

attributes

Yes

Check attributes

Diagnostics

Diagnostic
Contents

missingInTarget

Columns present in the source and absent from the target. Each entry is an object: the column name and its type, as declared on the source side.

missingInSource

Columns present in the target and absent from the source. Each entry is an object: the column name and its type, as declared on the target side.

typeMismatch

Columns present on both sides whose data types are not equivalent. Each entry carries sourceColumn and targetColumn — they differ when column_mapping maps a renamed column — together with sourceType and targetType.

The check's measured value is the total number of differences, that is the three groups summed; the diagnostics carry no separate count.

Data types are compared on a normalized form, so that types which mean the same thing in two different systems — for example VARCHAR(n) and TEXT — are not reported as a mismatch. Column names are compared using each dialect's identifier normalization rules.

Schema diff versus the schema check

Soda has two schema-related checks, and they answer different questions.

Check
Compares
Use it to

schema

The contract-declared columns against the actual columns of one dataset.

Detect drift away from the structure you have agreed on and written down.

schema_diff

The actual columns of the source dataset against the actual columns of the target dataset.

Detect that a migration, replication, or pipeline did not reproduce the structure of the system of record — regardless of what the contract declares.

They are complementary: a contract can declare its own structure with schema, and still assert with schema_diff that the upstream system it was copied from has not diverged.

Migrating a v3 "compare schema" check

In SodaCL (v3), a reconciliation block could contain a bare schema check, which compared the source and target schemas. There is no v4 equivalent in the core schema check — that one compares the contract to a single dataset. Use schema_diff instead.

SodaCL (v3)

Contract (v4)

Reconciliation checks are not translated by the v3 to v4 migration tooling; rewrite them by hand.

Filters and queries do not apply

filter, source_filter, target_filter, source_query and target_query are not valid on a schema_diff check. A schema is dataset metadata, not a row set, so a row filter has no meaning here — declaring one is a parse error.

The two per-column expression maps, source_column_expressions and target_column_expressions, are rejected the same way: a schema diff compares column names and data types, and there are no values for an expression to transform. Declaring either one is a parse error rather than a silently ignored key.

To narrow the comparison, use ignore_columns.


Caveats and limitations

Data should be quiescent during a scan

Row-level reconciliation reads both sides in pages, using LIMIT and OFFSET. Each page is a separate query execution, and Soda does not hold a snapshot across them — no transaction spans the scan, on either side.

If rows are inserted, deleted, or re-ordered between pages, rows can be read twice or skipped, and the resulting diff will report differences that do not exist in either dataset. This applies to tables, to views, and to custom SQL queries alike.

Recommendations

  • Schedule reconciliation scans in a window where the source and the target are not being written to — after a load completes, not during it.

  • Where a quiet window does not exist, filter both sides to a closed partition (for example, "yesterday") so that the rows under comparison can no longer change.

One dataset can back several sources

Entries in sources: may reference the same dataset, each through its own filter: — for instance to compare two filtered slices of one dataset against the target. Each check reads the slice of the source it is bound to.

Where the slicing only differs per check, a single source with per-check source_filters keeps the contract shorter.

Filters and identity

Neither filters nor custom queries participate in check identity. You can retune a filter, split a filter into source_filter and target_filter, or edit a query without losing check history in Soda Cloud.

Check identity is derived from the check's path, which includes the name of the source the check is bound to. Renaming a source, or moving a check to a different source, therefore starts a new history — see Migrating from single-source to multi-source.

Reconciling against a Salesforce source

Salesforce answers SOQL rather than SQL, so a subset of reconciliation checks cannot run against it and comparison is tolerant rather than exact.

row_count_diff, aggregate_diff, metric_diff, and freshness_diff work as documented above. rows_diff and reference_diff work when you name the columns explicitly on both sides. duplicate_diff reports NOT_EVALUATED, because SOQL cannot compute a per-row hash.

Two differences will affect how you write the checks:

  • Numbers are compared with a tolerance rather than exactly, so aggregate_diff on avg needs a tolerance threshold instead of must_be: 0.

  • Key columns must be orderable in SOQL, which rules out long text fields and descending order.

For the full list, including which filter expressions translate to SOQL, see Salesforce.


Migrating from single-source to multi-source

If you have an existing contract that uses the single-source source: syntax and want to add additional sources, you can migrate without losing check history in Soda Cloud.

Check identity in Soda Cloud is derived from the check's path. When you move from single-source to multi-source, checks that target the original source need to preserve their path so that Soda Cloud treats them as the same check and retains their historical results.

How to migrate

Use the default: true flag on a source without a name. This unnamed default source preserves the original check path format, so Soda Cloud continues to recognize those checks as the same ones from before the migration.

Before (single source):

After (multi-source, preserving history):

Key points

  • The original source becomes an entry with default: true and no name. This preserves check paths and therefore history.

  • Checks without a source: field continue to run against the default source.

  • New sources get a name, and checks targeting them use source: <name>.

  • default: true cannot be combined with name; the unnamed default is exclusively a migration mechanism.

  • Only one source can be default: true.


Legacy single-source syntax

The original single-source syntax uses source: (singular) instead of sources: (plural). This syntax is still supported and is equivalent to defining a single unnamed default source.

With single-source syntax, checks do not need a source: field since there is only one source to target.

If you are writing a new contract, use the sources: (plural) syntax instead. See Migrating from single-source to multi-source if you want to add additional sources to an existing single-source contract.


Reconciliation checks with in-memory data sources (DuckDB, Spark)

Reconciliation checks can compare datasets that live in different execution contexts, such as:

  • a dataset in a database (for example, PostgreSQL, Snowflake), and

  • a dataset in an in-memory data source (for example, DuckDB in-memory or a Spark Dataframe).


Compare DuckDB datasets

To compare DuckDB datasets, you must pass two different values in data_sources:

  1. The in-memory data source

  2. The database data source

Compare DuckDB to another data source

If you want to compare a DuckDB dataset to a different data source (for example, Postgres), you must pass DuckDB in data_sources and Data source config in data_source_file_paths:

  1. The in-memory data source, passed directly as a data_sources object

  2. The database data source, passed via a data_source_file_paths configuration file

Example: DuckDB (in-memory) vs PostgreSQL

The example below shows how to reconcile a DuckDB in-memory dataset with a PostgreSQL dataset using verify_contract_locally:

Spark DataFrames

The same pattern applies when reconciling against Spark DataFrames:

  • The Spark DataFrame is passed as an in-memory data source

  • The database connection is provided via a YAML file in data_source_file_paths


You are not logged in to Soda and are viewing the default public documentation. Learn more about Licensing & documentation access.

If you do have a Soda license, make sure to log in to Soda Cloud in this same browser.

Last updated

Was this helpful?