Start with the summary
Every result carries asummary object. Read it before anything else:
result_mode only appears in this object when a run sets it explicitly, either per pair in reconify.yaml or with --result-mode. A default all run omits the field rather than printing it.
Every monetary field Reconify emits is in minor units: a total_left amount of 184230000 means 1,842,300.00. A healthy run looks like high matched and low everything else.
Two rates, two questions
match_rate_pct and reconciled_rate_pct answer different questions, and it’s easy to misread one for the other.
match_rate_pct counts only exact one-to-one reference matches: same reference, amount within tolerance, date within window. reconciled_rate_pct is broader: it includes amount diffs, timing diffs, and grouped outcomes, every case where the two sides genuinely reconciled to each other, just not perfectly. Both are percentages of max(total_left, total_right).
Watch this with grouped passes: one_to_many inflates total_right because N right rows correspond to one left row. A fully reconciled grouped dataset can legitimately report a sub-100% reconciled_rate_pct. That’s expected, not a sign of a broken mapping.
Work through the buckets
Order matters here: each step tells you something the next step depends on.1
Check duplicates first
A non-zeroduplicate_count means rows in one source share a grouping key (group_col, falling back to ref_col). It’s an annotation, not a filter: every row, duplicate or not, still went through matching.duplicate_count is a count of transactions across all duplicate groups, not a count of groups. See Matching algorithm for the full mechanics, including the installment-payment case where legitimate repeats need a separate group_col.2
Read amount_diff and timing_diff next
Both mean the reference matched on both sides; only the amount or the date disagreed.timing_diff far more often means a date_layout or tz mistake than a real reconciling item: check that before assuming the two sides genuinely settled on different days. amount_diff usually means one side is gross and the other net of fees, refunds, or reserves.3
Then look at unmatched rows
Three usual causes: the reference format differs between sources (TXN-099 on one side, 099 on the other), the row genuinely has no counterpart yet, or the date falls outside date_window. Check reference format first; it’s the most common and the cheapest to confirm.4
Finally, resolve ambiguous groups
ambiguous_group events come from one_to_many or many_to_many passes where more than one left row shares the same reference, so grouping was undetermined. These rows are excluded from matching entirely and need manual resolution; Reconify won’t guess which left row a group belongs to.Account for every currency unit
Reconify’s discrepancy identity should account for every unmatched or ambiguous minor unit on both sides:total_discrepancy alongside the matched count you expect means a clean run. Non-zero is exactly the money still unexplained; it’s the number to chase before signing off on a reconciliation.
Summarise a finished run
explain reads a completed JSON, JSON-stream, or NDJSON result without rerunning reconciliation. It returns reconify.engine.explanation.v1: the original summary, deterministic finding counts, and up to --top exception event payloads (10 by default). Use --top 0 to keep the counts and drop the payloads entirely.
If the input file was written with result_mode: summary_only, explain still reports the counts but can’t produce payloads that were never in the file to begin with.
Emit less
result_mode controls which events the writer emits: all (every event, the default), exceptions_only (unmatched, diffs, duplicates, ambiguous groups; clean matches suppressed), or summary_only (counts only, no item events). Set it per pair in the config, or override at runtime with --result-mode; the flag wins over the config value.
currency in the summary are always computed from the full reconciliation. Suppressing events never changes the numbers, only which rows you can see.
Make the run auditable
--audit adds a run_info object to structured output: a run ID, UTC timestamp, tool version, resolved input paths, a SHA-256 hash and size_bytes/mod_time for each input file, and a snapshot of the applied pair config. It’s supported for json, json-stream, and ndjson, not csv or table. For NDJSON, run_info is always the first line of the file.
Make it byte-identical
--deterministic sorts JSON output sections for stable key ordering; it only affects buffered json output. --audit-fixed-timestamp fixes the run ID and timestamp instead of letting them change on every run. Together, two runs on the same input files produce the same output bytes, which you can prove directly:
--deterministic and --audit-fixed-timestamp are both set and the inputs haven’t changed.
Fail a pipeline on exceptions
--fail-if-unmatched exits 3 if either side has unmatched rows after a completed run. --fail-if-exceptions exits 4 if any amount_diff, timing_diff, or unmatched event was emitted; it’s a superset of --fail-if-unmatched, and when both flags are set and both conditions hold, 4 takes precedence over 3.
Both flags complete the run and write output first. The exit code is a policy decision your CI makes afterward, not a sign the run itself failed.