mode:. The mode decides what “agree” means.
Row count mode
COUNT(*) per side. Use it when the target changes the values, so that a checksum can never match, but must not lose or duplicate rows. A drill-down still works, and finds which key ranges have the wrong count.
This mode reads no column, so the two sides do not need the same columns. A target can have columns that the source does not have, such as surrogate keys, load timestamps or extra dimensions that the warehouse adds. It is compared as it is, and you do not have to list columns to make the two sides equal. Only the key columns have to exist on both sides, so the drill-down can range over them.
A tolerance on the count
A replica that always lags a little, behind a CDC stream or a loader that runs every few minutes, never has exactly the source’s count.row_count.thresholds says how far apart the two counts may be:
absolute, percentage and percentage_mode, with the same meaning as in aggregate thresholds; per_column and per_measure belong to aggregate mode and are rejected here. The counts are compared as integers. A difference inside the tolerance reports within_threshold and is not drilled into, since the tolerance says it is expected. A difference past it is mismatched and drills down as usual. row_count is read only in row_count mode, and other modes ignore it.
Row checksum mode
columns: and exclude_columns: to keep pipeline bookkeeping columns out of the comparison, such as load timestamps, batch ids, or surrogate keys that each side generates. Otherwise, each run reports them as a difference.
Each row’s hash runs over the compared columns in order, position by position, so both sides must present the same number of columns. A target that is wider than the source is the usual reason a run stops with source has N columns but target has M columns; name the columns you actually want compared, on both sides:
exclude_columns: on the wider side does the same job when it is easier to name what to drop than what to keep. If you only care that no rows went missing, mode: row_count compares the two sides with no column list at all.
full is an accepted legacy spelling of row_checksum. Suites round-tripped through synq-recon suite yaml come back written as row_checksum.Numeric precision
Inrow_checksum mode, every value is converted to text before it is hashed. Two databases can write the same number as different text: 5, 5.0 and 5.00, or a floating-point value with a different number of digits. For this reason, decimal and floating-point columns are converted to one text form on both sides. The precision of that text form decides which differences the checksum can find.
Default precision
If you do not setcomparison, the column types on the two sides decide the precision:
The scale is the scale that the database reports for the column of your query. The type name alone does not decide it. Thus a Snowflake or Oracle
NUMBER with scale 0 is an integer. If only one side reports a scale, the scale of that side is used. The warning for 6 decimal places names the side that does not report its scale.
A floating-point value has no fixed scale, so it is compared to a number of significant digits, not to a number of decimal places. A double holds 15 significant digits exactly. Thus 0.1 + 0.2 (0.30000000000000004) and 0.3 agree. NaN, Infinity and -Infinity agree only with the same value on the other side.
The warning in the audit log tells you that a match on that column is a match at that precision only.
Between two different kinds of database, floating-point values below about 1e-8 or above about 1e37 can differ in the last of the 15 digits. If a column holds such values, set
numeric_scale for it.Set the precision with comparison
The comparison block replaces the default precision. You can write it at the top level of the suite, on a reconciliation, or both.
numeric_scale has no effect on text, timestamp, boolean or UUID columns, or on a pair of two integer columns. An integer paired with a decimal or a floating-point column is compared at the numeric_scale you set.
The two values of numeric_scale do different things:
- A number of decimal places
- exact
Which setting applies
A column can get a setting from four places. The first setting in this list that exists applies:- The entry for the column in the reconciliation’s
comparison.columns. - The reconciliation’s
comparison.numeric_scale. - The entry for the column in the suite’s
comparison.columns. - The suite’s
comparison.numeric_scale.
numeric_scale applies before a suite’s entry for the same column.
payments compares fx_rate at 8 decimal places and every other decimal column at its declared scale. All other reconciliations in the suite compare decimal and floating-point columns at 4 decimal places.
comparison applies only in row_checksum mode. row_count mode reads no column values, and aggregate mode compares measures as numbers. If you set comparison on a row_count or aggregate reconciliation, or on a suite that has no row_checksum reconciliation, check-config and the run give a warning that names the field. The suite still loads.
In the app, set it on the Comparison tab of a reconciliation, under Precision, or for the whole suite on the suite’s Settings tab. Add a column under Column settings to set its own precision. When the warehouse reports the column’s type, the form shows the precision the column gets by default, and warns when exact would fail the run.
Find problems before a run
check-config --db makes the same checks as a run for each reconciliation that has comparison settings. It shows a column that exact cannot compare:
comparison.columns names a column that the reconciliation does not compare, the run adds a warning to the audit log. An entry at the suite level applies only to the reconciliations that have that column, so it gives no warning.
See the precision of a run
The audit log records the precision of each decimal and floating-point column innormalization.numeric_columns. This includes the columns that needed no conversion, such as two decimals of the same type on the same kind of database:
decimal_places, significant_digits or exact. A column that needed no conversion and has no reported scale is recorded as exact, because each side keeps all of its digits. configured: true shows that a comparison setting gave the precision. Without it, the column types gave the precision.
Timestamp precision and time zones
A timestamp is converted to one text form on both sides too,2026-01-08 14:05:00.123456, with a timestamp that has a time zone written in UTC. By default it is compared at the precision that both sides hold, the coarser of the two, and between two different kinds of database to the microsecond at most. A date is compared as the day. A date on one side against a timestamp on the other is compared by day too, 2026-01-08 on both sides, which is how an Oracle DATE (which holds a time) matches a DATE it was loaded into on another warehouse.
The same rules apply to a date or timestamp key column. The drill-down splits on it like on any key, and a source that holds nanoseconds against a target that holds microseconds puts each row in the same segment on both sides.
Two things cannot be read from the column types, so comparison has a setting for each:
second, millisecond and microsecond cut both sides to that precision, so a value that differs only beyond it matches. Use it when a loader cut the values on one side. exact compares every digit that both sides hold: on one kind of database the column as the database writes it, and between two kinds of database only when both sides hold the same precision, at most microseconds. Otherwise the reconciliation fails with a message that names the column.
A timestamp without a time zone is compared as its wall clock, read as UTC. When a loader moved local wall clocks to UTC, the two sides hold different wall clocks for the same row, and nothing in the types says so. Name the time zone of each side, and both are compared as the instant they are:
numeric_scale, and each side’s time zone on its own: a column’s source_time_zone does not hide the reconciliation’s target_time_zone. In the app, set them under Precision, next to numeric precision.
Aggregate mode
Row-level comparison is the wrong instrument when the target is a summary of the source, when row identity differs between the two systems, or when you care about the numbers rather than the rows. Aggregate mode compares measures, grouped, with a tolerance.Measures
A measure is a column and one or more functions.function: takes a single value or a list, so the block above expands to two measures: SUM(amount) and COUNT(amount). Available functions are SUM, COUNT, AVG, MIN and MAX.
To count rows rather than the non-null values of a column, write column: "*" with COUNT. It is compared as COUNT(*) and reported as _measure_count_all. "*" takes no other function, and a suite that pairs it with one fails to load.
The group hierarchy
group_columns: is a hierarchy, evaluated outermost-first. The comparison groups by the first column; only groups that diverge are then broken down by the second, and so on. Groups that agree are never queried again.
With group_columns: [sale_date, store_id], a run over a discrepancy on one store on one day looks like this:
STORE-1’s numbers that are wrong. Two levels, and the finding is specific enough to act on.
Each measure shows as _measure_<function>_<column>. This is the alias in the generated SQL, and the name in -o json and in the audit log. Each divergent node has a label that tells why it is there:
A group is matched across the two sides by its key, so each side reads a group column in one text form, whatever type it holds the column in and however its warehouse would print it: a date as
2026-01-08, a timestamp as 2026-01-08 14:05:00.000000 (in UTC when it carries a time zone), a decimal at one scale on both sides (52.50, never 52.5 against 52.50), a floating-point value at 15 significant digits (also against an integer or a decimal, so 5 matches 5.0), and a boolean as 1 or 0, so it lines up with a number on a warehouse that has no boolean type. A date on one side against a datetime on the other is compared by day, which is how a DATE loaded into an Oracle DATE finds its source. Types with nothing in common, such as text against a number, are compared as each warehouse prints them, so a value printed differently shows up as a group missing on each side: cast one side in its query to match the other.
If you omit group_columns, the hierarchy falls back to [key_column]. A key is usually unique, so that makes one group per row: fine for a small table, but each level holds one side’s groups while it compares the other, and a level with more than 100,000 groups on either side fails the reconciliation instead. To compare row counts use row_count mode, with a tolerance if the two sides may differ a little, and to compare row by row use row_checksum.
Comparing the whole table
Leave out bothgroup_columns and the key columns, and the aggregate compares the whole dataset as a single group: one query per side, with no GROUP BY. This is the way to say “the total SUM(amount) and COUNT(amount) must agree, within a tolerance”.
COUNT of 0 against the other side’s) rather than reported missing. When the totals differ, the result has one MEASURE_MISMATCH with no group key. To find where the difference is, resume the run with synq-recon drill-deeper --add-group-column <column>: the whole table is then broken down by that column, as if it had been the first group column.
A key column set on the reconciliation takes over as the group column, so remove it to compare the whole table.
Thresholds
Floating-point arithmetic, rounding, and currency conversion all produce differences that are not errors. A threshold is what separates them from the ones that are.absolute: 0 means exact agreement.
Integer and decimal measures are compared exactly, so two large sums that differ only in their last digits still differ. A measure that is NULL on one side and a number on the other, such as a SUM over NULL amounts that the target loaded as 0, is a mismatch whatever the threshold. NULL on both sides agrees.
You can override the threshold for a group column, and for a measure:
1
per_column[column].per_measure[measure]
The threshold for this measure at this level of the hierarchy.
2
per_measure[measure]
The threshold for this measure, at every level.
3
per_column[column]
The threshold for every measure at this level.
4
thresholds
The default for every measure of the reconciliation, at every level.
"FUNC(column)", for example "SUM(revenue)" or "COUNT(amount)". The quotes are necessary because of the parentheses.
Hash algorithms
row_checksum needs a hash function that both databases compute identically. The tool negotiates the best one available on both sides, so you normally do not think about it:
Set
hash_algorithm: (auto, md5, farm_fingerprint, xxhash64) only when you must fix it, for example to keep the checksums the same during a platform migration.
Next
How comparison works
The checksum formula, bisection, segmentation strategies and privacy levels.
Time windows and cutoffs
Aggregate mode on a lagging target needs a cutoff. Here is why.
Investigating results
Reading a drill tree, and following a finding to the rows.
Configuration reference
Every field of
aggregate: and thresholds:, generated.