Skip to main content
Most “the data is wrong” findings are not data problems. They are comparison-window problems: two systems read at slightly different moments, or a target that has not finished catching up. This page is how you rule that out. Reach for these in order:

Template variables

A variable is resolved once per run and interpolated into both sides, so the two queries are guaranteed to use the same boundary.
Variables also interpolate into a table dataset’s where:, so you do not have to switch to raw SQL to use them.

What you can write inside {{ }}

Offsets subtract from now, today or yesterday, in units of d (days), h (hours), m (minutes) or w (weeks). Only subtraction is supported, because a comparison window must not go into the future. Override any variable for a single run:
The resolved values are recorded in the audit log, which is what makes a re-check compare the same window as the original run.
NOW(), CURRENT_DATE and GETDATE() in a query are a bug, not a shortcut. Each side evaluates them at its own moment, on its own clock. A row written between the two evaluations is on one side and not on the other. The result is a mismatch that is not in your data. It occurs on every run, each time for a different row.check-config warns when it finds one. Set strict_time_references: true at suite level to make it an error, which is worth doing in CI.

Windows

A window: restricts a reconciliation to a recent period without you writing the predicate:
  • lookback is required, and positive: how far back from the run the window reaches, in any of the duration forms ("14d", "P14D", "14 days", {count: 14, period: day}). A zero or negative lookback is ignored, with a warning, and every row is compared.
  • column is the timestamp or date column each table side is filtered on. The filter is written for the column’s type, so a DATE column is compared with a date, and it is added to any where: you wrote rather than replacing it.
  • strategy: sliding (the default) measures back from the run’s reference time, so each run compares a moving fortnight. fixed ends the window at the last period boundary in UTC and starts it one lookback earlier, so consecutive runs inside the same period compare exactly the same rows. The period follows the lookback: midnight for a whole number of days, the start of the hour for whole hours, otherwise the minute.
Use a window when history is immutable and re-comparing it every hour is a waste of warehouse spend. Use fixed when you want two runs to be comparable to each other, not just each correct in isolation. Every window also defines two template variables for its own reconciliation: {{ window_start }}, and {{ window_end }} (the run’s reference time, or the period boundary for a fixed window). Use them where the column filter does not reach:
  • A query: side is never filtered on the column. You wrote its SQL, so filter it yourself:
  • A where: that already references {{ window_start }} or {{ window_end }} is kept exactly as written, and the column is not filtered a second time on that side. A window without a column filters nothing on its own and only defines the variables.
A cutoff on top of a window reads its watermark from inside the window, so a MAX() over a large table scans only the recent rows.

Durations

window.lookback, cutoff.offset, the timeouts and error_handling durations all take the same forms: A leading - makes a duration negative, which is what a cutoff offset usually is. Years and months are refused, because they are not a fixed length: write the days instead, for example P30D. A duration longer than the time since 1970 still loads, with a warning, since it is almost always a wrong unit or a typo. synq-recon suite yaml and the app write durations back in the short forms of the first two rows (14d, 1h30m), which every CLI release reads.

Cutoffs for a lagging target

This is the important one, and the one people reach for last when they should reach for it first. If the target is behind the source (a CDC stream, a batch load, a read replica), the most recent rows are on the source and not yet on the target. They are not missing. They are in flight. A reconciliation with no cutoff reports them as a difference, every time, and the difference moves as the lag moves. A cutoff: derives a watermark from the data itself, then filters both sides to at-or-below it. Rows in flight are excluded from the comparison rather than reported as errors.
The result on the same pair of tables, a primary with 500 orders and a replica that has 470 of them:
The data and the query are the same, but the results are different. The second result is correct: the replica is not missing rows, it is thirty rows behind. With the cutoff, the comparison shows this, because it agrees about the 415 rows that both sides certainly have.
The cutoff is stricter than the lag: 415 rows, not 470. truncate: HOUR moves the watermark down to the start of the hour, and offset: "-5m" moves it 5 minutes further back. Both give a safety margin, because a pipeline that writes slightly out of order can write a row below a watermark that it already passed.

Three levels of control

One column, both sides:
Each side’s watermark is MAX(created_at) over its own dataset; the lower of the two becomes the cutoff.

How a cutoff resolves

1

Derive a watermark per configured side

MAX (or MIN) of the named column, or the result of your custom query.
2

Combine the two into one value

combine: min by default.
3

Truncate to a boundary

Optional. Only for a timestamp watermark.
4

Apply the offset

Optional, after truncation. Negative buys margin.
5

Build the WHERE clause for each side

Against the derived column, or whatever apply: names.
The audit log records each of these values: the two watermarks, the combined cutoff, and the final WHERE clause of each side. Thus you can always find the comparison window of a run later. In the app, the Overview of a run shows them under Cutoff. This is the quickest way to check that an offset or truncate does what you want.
combine: min is the default and almost always the right answer. The lower of the two watermarks is the point both sides have certainly reached.max, source and target each keep rows that one side has and the other side possibly does not have. This makes a mismatch. Use them only when you know that the difference between the sides is real. If you are not sure, use min.
The watermark does not have to be a timestamp. A sequence or an id that only increases works in the same way. With such a watermark, do not set truncate or offset: on a value that is not a timestamp, either one makes the reconciliation fail.

Time travel

as_of compares a snapshot at a point in time, not the current state. Snowflake, BigQuery and Databricks support it:
This is the tool for “was it right yesterday, before the deploy?” and for pinning both sides to the same instant when one of them is being written to continuously. A window with a column still filters a table side that has as_of, applied to the snapshot. If the side lists columns, the window column has to be one of them.
A re-check replays the original run’s queries, which for an as_of reconciliation means it re-compares the same frozen snapshot and can therefore never turn green. Pass --reresolve to re-derive the queries against current data.

Choosing between them

Use a cutoff when

The two systems are the same data at different times. You want to compare what both sides have, and ignore what is still on its way.

Use a window when

History does not change and only recent data is important to you. This is usually for cost, not for correctness.

Use variables when

You need an explicit, reproducible boundary in your own SQL, identical on both sides.

Use as_of when

You need a specific historical state, not “now”.
They compose. A cutoff on top of a window is a normal thing to write: compare the last fortnight, up to the point the target has caught up to.

Next

Running locally

Overriding variables per run, and reading what a run resolved them to.

Investigating results

Confirming a difference is real, and re-checking after a fix.