parity-diff 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,149 @@
1
+ # Contributing
2
+
3
+ The most valuable contribution is **a new dialect**. The architecture exists to
4
+ make that a single file of roughly eighty lines that touches nothing else.
5
+
6
+ ## Adding a dialect
7
+
8
+ The bisection engine knows nothing about SQL. It talks to two `Dialect` objects
9
+ through a contract, so adding Snowflake or BigQuery means writing one new file
10
+ in `src/parity/dialects/` and registering it in `get_dialect()`. If you find
11
+ yourself editing `engine.py`, the abstraction is wrong — say so in an issue
12
+ rather than working around it.
13
+
14
+ ### What you implement
15
+
16
+ Nine methods, all abstract:
17
+
18
+ ```python
19
+ class Dialect(ABC):
20
+ name: str
21
+ default_schema: str # "public", "main", ...
22
+
23
+ def connect(self, connection_string: str) -> None: ...
24
+ def close(self) -> None: ...
25
+ def query(self, sql: str) -> list[tuple]: ...
26
+ def quote(self, identifier: str) -> str: ...
27
+ def normalize(self, column: Column) -> str: # canonical text, null-safe
28
+ def hash_expr(self, text_expr: str) -> str: # -> 60-bit integer
29
+ def int_div(self, num: str, den: str) -> str: # truncating division
30
+ def sum_wide(self, expr: str) -> str: # overflow-safe sum
31
+ def wide_int(self, expr: str) -> str: # widen past 64 bits
32
+ ```
33
+
34
+ Everything else is inherited and you should not need to touch it: the base
35
+ class introspects columns from `information_schema` (override `columns` only if
36
+ your engine has no such view), builds the row text, the per-segment checksum
37
+ query and the small-range fetch, and splits `schema.table`. Two optional hooks
38
+ have sensible defaults — `cancel()` for aborting an in-flight query from
39
+ another thread, and `_exists_but_unreadable()` for engines whose catalog hides
40
+ tables the current role lacks privileges on.
41
+
42
+ That comes to roughly 70–85 lines. Read
43
+ `src/parity/dialects/duckdb_dialect.py` first — it is the shortest complete
44
+ example.
45
+
46
+ ### The six things that will bite you
47
+
48
+ Each of these cost real time to discover. They are documented at length in
49
+ `CLAUDE.md` §4; the short version:
50
+
51
+ 1. **The hash must be exactly 60 bits.** 15 hex characters of an MD5 digest.
52
+ That is the widest prefix both PostgreSQL and DuckDB render as the same
53
+ *positive* signed 64-bit integer. At 64 bits PostgreSQL wraps negative and
54
+ the engines disagree. Your dialect must produce `648541476951500027` for
55
+ input `'abc'` — there is a test that checks exactly this.
56
+
57
+ 2. **The sum must be widened.** Row hashes reach 2^60, so summing a few million
58
+ overflows a 64-bit accumulator. Aggregate in `numeric`, `decimal(38,0)`, or
59
+ whatever your engine's arbitrary-precision type is. And wrap it in
60
+ `coalesce(..., 0)`: an empty segment returns SQL `NULL` from `sum()`, and an
61
+ empty bucket on one side must compare equal to an empty bucket on the other
62
+ or the walker recurses into nothing.
63
+
64
+ 3. **`NULL` must render as the literal `\N`, never SQL NULL.** An un-coalesced
65
+ NULL poisons the whole concatenation and silently masks differences. Watch
66
+ `CASE` expressions especially: `case when c then 'true' else 'false' end`
67
+ sends NULL down the `else` branch, so a NULL boolean renders `'false'` and
68
+ compares equal to a real FALSE. Both engines agreed on that wrong answer for
69
+ a while. Use `case when c then 'true' when not c then 'false' end`.
70
+
71
+ 4. **`/` is not portable.** PostgreSQL truncates on integer operands, DuckDB
72
+ promotes to double. That is what `int_div` is for. Do not reach for
73
+ `floor(a/b)` — double precision silently breaks on large key ranges.
74
+
75
+ 5. **Prefer summing to XOR.** `bit_xor` exists in most engines and silently
76
+ cancels duplicate rows, which is precisely the difference you need to see.
77
+
78
+ 6. **`wide_int` has to widen the key *before* the arithmetic, not after.** The
79
+ bucket expression computes `(key - lo) * n_segments`, and a key range as
80
+ wide as bigint overflows in three separate places. `wide_int(k - lo)` looks
81
+ right and does nothing, because the subtraction already happened in the
82
+ column's own type. Return something that survives 128 bits — `hugeint`,
83
+ `numeric`, `NUMERIC(38,0)` — and check `int_div` still truncates on that
84
+ type rather than producing a scaled or rounded result.
85
+
86
+ ### Proving it works
87
+
88
+ A dialect is not done until `tests/test_encoding.py` passes against it. That
89
+ file is the correctness contract: it inserts the same literal into your engine
90
+ and into a reference engine and asserts the canonical text is byte-identical.
91
+
92
+ Add your engine to the fixtures there and run:
93
+
94
+ ```bash
95
+ pytest tests/test_encoding.py -v
96
+ ```
97
+
98
+ **Every test must plant a difference.** A test that only asserts identical
99
+ tables match passes trivially for a completely broken tool — this is the single
100
+ rule the project cares most about. For each positive assertion ("these agree"),
101
+ add the negative control ("and the harness notices when they genuinely don't").
102
+ That discipline is what caught the boolean NULL bug in point 3 above.
103
+
104
+ ## Running the checks
105
+
106
+ ```bash
107
+ pip install -e ".[all]" pytest mypy ruff
108
+ pytest
109
+ ruff check src tests demo
110
+ mypy src/parity --strict --ignore-missing-imports
111
+ ```
112
+
113
+ CI runs the static checks first, because they take seconds where the test
114
+ matrix takes minutes. `mypy --strict` is what turns "type hints everywhere"
115
+ from an aspiration into a fact, and ruff's `ISC`, `BLE` and `S` rules are on
116
+ deliberately: implicit string concatenation inside a collection is the
117
+ missing-comma bug class, a blind `except` has to be justified where it sits,
118
+ and this tool builds SQL by hand so injection rules earn their place. Where a
119
+ rule is knowingly not applicable, the ignore lives in `pyproject.toml` with the
120
+ reason next to it rather than being switched off globally.
121
+
122
+ PostgreSQL-backed tests read `PARITY_TEST_PG` and skip cleanly when nothing is
123
+ listening, so the suite is useful with only DuckDB installed.
124
+
125
+ ## Scope
126
+
127
+ Before proposing a feature, check it against the question the tool exists to
128
+ answer: *"can I safely switch off the old system?"* Data quality rules,
129
+ freshness checks, lineage, cataloguing, orchestration, a web UI, and schema
130
+ migration are all deliberately out of scope. The open-source predecessor in
131
+ this category was abandoned because maintaining it grew expensive; a narrow
132
+ scope is the only defence.
133
+
134
+ Non-integer and composite keys, sampling mode, and a dbt integration are
135
+ planned and welcome.
136
+
137
+ ## Style
138
+
139
+ - Python 3.10+, `from __future__ import annotations` at the top of every module.
140
+ - Type hints everywhere. Dataclasses for value types. No ORM — emitting dialect
141
+ SQL deliberately is the product.
142
+ - No network calls, no telemetry. This tool points at production warehouses;
143
+ trust is the whole distribution strategy.
144
+ - Read-only by construction. The tool issues `SELECT` only and never generates
145
+ DDL or DML against a user's database.
146
+ - Comments explain *why*, especially for cross-engine workarounds — each one is
147
+ a landmine for the next person.
148
+ - Errors must name the side and the table. `"table not found"` is useless when
149
+ two databases are in play.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Alessio Sorio
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,10 @@
1
+ # The sdist shipped `test_*.py` but not `conftest.py` or `fakes.py`, so the
2
+ # tests it carried could not actually run. For a tool whose whole pitch is
3
+ # trustworthiness, a downstream packager has to be able to verify it - so ship
4
+ # a suite that works, or none at all.
5
+ include LICENSE
6
+ include README.md
7
+ include CONTRIBUTING.md
8
+ recursive-include tests *.py
9
+ prune demo/data
10
+ global-exclude __pycache__ *.py[cod]
@@ -0,0 +1,268 @@
1
+ Metadata-Version: 2.4
2
+ Name: parity-diff
3
+ Version: 0.1.0
4
+ Summary: Prove two tables in two different database engines hold the same data - without moving the data out of either engine.
5
+ Author: Alessio Sorio
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/Aleixiou/parity
8
+ Project-URL: Repository, https://github.com/Aleixiou/parity
9
+ Project-URL: Issues, https://github.com/Aleixiou/parity/issues
10
+ Keywords: data-diff,migration,postgres,duckdb,data-quality,cutover,warehouse,parity
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: System Administrators
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Database
22
+ Classifier: Topic :: Software Development :: Quality Assurance
23
+ Classifier: Topic :: Utilities
24
+ Requires-Python: >=3.10
25
+ Description-Content-Type: text/markdown
26
+ License-File: LICENSE
27
+ Provides-Extra: duckdb
28
+ Requires-Dist: duckdb>=1.0; extra == "duckdb"
29
+ Provides-Extra: postgres
30
+ Requires-Dist: psycopg[binary]>=3.1; extra == "postgres"
31
+ Provides-Extra: all
32
+ Requires-Dist: duckdb>=1.0; extra == "all"
33
+ Requires-Dist: psycopg[binary]>=3.1; extra == "all"
34
+ Dynamic: license-file
35
+
36
+ # parity
37
+
38
+ [![tests](https://github.com/Aleixiou/parity/actions/workflows/tests.yml/badge.svg)](https://github.com/Aleixiou/parity/actions/workflows/tests.yml)
39
+
40
+ **Prove two tables in two different database engines hold the same data —
41
+ without moving the data out of either engine.**
42
+
43
+ Migrations don't fail on translation. They fail at cutover, because nobody can
44
+ prove the new pipeline produces the same data as the old one — so the legacy
45
+ system runs in parallel "just to be safe", forever, at double the cost.
46
+ `parity` is the proof.
47
+
48
+ ```bash
49
+ parity diff \
50
+ --a "postgres://user:pw@legacy-host/warehouse" --a-table public.orders \
51
+ --b "duckdb:///./new.duckdb" --b-table main.orders \
52
+ --key order_id
53
+ ```
54
+
55
+ ```
56
+ ✗ 5 differences in 10,000,000 rows
57
+ 28 queries · 7,628 rows downloaded (0.04% of both tables) · 54.2s
58
+ 1 only in A · 1 only in B · 3 different
59
+
60
+ only in A key 999999999
61
+ only in B key 4000
62
+ different key 13 columns: note
63
+ note A '' B NULL
64
+ different key 6010000 columns: amount
65
+ amount A 700.010000 B 700.000000
66
+ different key 8700000 columns: is_refunded
67
+ is_refunded A NULL B false
68
+
69
+ comparing floats and decimals at 6 decimal places
70
+ ```
71
+
72
+ Exit code `1`. Put it in CI and the build fails until the data agrees.
73
+
74
+ ## How it works
75
+
76
+ `parity` pushes hash aggregation **down into both engines**. It asks each side
77
+ for one checksum per bucket of the key range, compares a handful of integers,
78
+ recurses only into the buckets that disagree, and downloads rows only from
79
+ ranges already proven to differ.
80
+
81
+ On identical tables it downloads **zero rows** and issues **four queries**,
82
+ whether the table has ten thousand rows or ten million.
83
+
84
+ ## Measured
85
+
86
+ 10,000,000 rows per side, PostgreSQL 18.4 ↔ DuckDB 1.5.5, median of five runs
87
+ on one developer laptop (`demo/benchmark.py`):
88
+
89
+ | Scenario | Queries | Rows downloaded | Wall time |
90
+ |---|---|---|---|
91
+ | identical tables | 4 | **0** (0.0000%) | 26.9s |
92
+ | 5 planted differences | 28 | 7,628 (0.0381%) | 54.2s |
93
+
94
+ The query count and the rows-downloaded figures are **exact and
95
+ hardware-independent** — they are properties of the algorithm, and the test
96
+ suite pins them. The wall times are one machine under sustained load; treat
97
+ them as an order of magnitude, not a specification.
98
+
99
+ The five planted differences — a changed decimal, a deleted row, an inserted
100
+ row at key 999,999,999, a `NULL` turned into `''`, and a `FALSE` turned into
101
+ `NULL` — are all found exactly, with no false positives.
102
+
103
+ Cost is roughly **one full hash pass per side**. It is CPU-bound on MD5, not
104
+ IO-bound on key lookup, which is why **an index on the key column makes no
105
+ measurable difference** (measured at 10M: 38.8s without, 37.3s with).
106
+ Raising
107
+ `--bisection-factor` does not speed up the dominant first pass; it only reduces
108
+ round trips on later levels.
109
+
110
+ ## Install
111
+
112
+ ```bash
113
+ pip install "parity-diff[all]" # both engines
114
+ pip install "parity-diff[duckdb]" # DuckDB only — no PostgreSQL driver pulled in
115
+ pip install "parity-diff[postgres]" # PostgreSQL only
116
+ ```
117
+
118
+ > The PyPI distribution is `parity-diff` — plain `parity` is squatted by an
119
+ > empty project. The command you run and the module you import are both
120
+ > `parity`.
121
+
122
+ Python 3.10+. The core has no dependencies; drivers are optional extras and are
123
+ imported lazily, so a DuckDB-only user is never made to install `psycopg`.
124
+
125
+ ## Usage
126
+
127
+ ```
128
+ parity diff --a CONN --a-table TABLE --b CONN --b-table TABLE --key COL
129
+ [--columns a,b,c] [--exclude x,y]
130
+ [--bisection-factor 32] [--threshold 10000] [--float-scale 6]
131
+ [--max-diffs 100] [--json] [--quiet]
132
+ ```
133
+
134
+ **Exit codes** — this is what makes it a CI check:
135
+
136
+ | Code | Meaning |
137
+ |---|---|
138
+ | `0` | identical |
139
+ | `1` | differences found |
140
+ | `2` | error (bad connection string, missing table, unusable key) |
141
+
142
+ An error is never `1`. A CI job can always tell "the tables differ" from "the
143
+ tool could not run".
144
+
145
+ Connection strings:
146
+
147
+ ```
148
+ postgres://user:password@host:port/database (also postgresql://)
149
+ duckdb:///relative/path.duckdb (three slashes = relative)
150
+ duckdb:////var/lib/warehouse.duckdb (four slashes = absolute)
151
+ duckdb:///C:/data/warehouse.duckdb (absolute, Windows)
152
+ duckdb:///:memory:
153
+ ```
154
+
155
+ Table names may be schema-qualified. Unqualified names default to `public` on
156
+ PostgreSQL and `main` on DuckDB.
157
+
158
+ `--json` emits the same content as a machine-readable object, including
159
+ `identical`, `truncated`, per-difference values, and the full stats block.
160
+
161
+ ### In CI
162
+
163
+ ```yaml
164
+ - name: prove the migration is complete
165
+ run: |
166
+ parity diff \
167
+ --a "$LEGACY_URL" --a-table public.orders \
168
+ --b "$WAREHOUSE_URL" --b-table analytics.orders \
169
+ --key order_id --quiet
170
+ ```
171
+
172
+ ## Limitations — read these before trusting a result
173
+
174
+ A parity tool that reports a false match is worse than useless, so these are
175
+ stated plainly rather than buried.
176
+
177
+ - **Integer keys only.** The bisection arithmetic divides the key range. A
178
+ `varchar` or `uuid` key is rejected with a clear message, not guessed at.
179
+ Composite and hashed keys are a planned extension.
180
+ - **Floats and decimals are compared at 6 decimal places** by default. Two
181
+ values differing only in the 7th place are reported as *equal*. This is a
182
+ deliberate cross-engine rounding contract — the two engines do not otherwise
183
+ agree on float text. Change it with `--float-scale`; it always applies to
184
+ both sides, and the scale in force is printed on every run.
185
+ - **Keys must be unique.** A non-unique key is detected up front and rejected.
186
+ Silently collapsing duplicate rows would hide real differences.
187
+ - **`Infinity` and `NaN`** in float columns are compared as those literal
188
+ tokens on both sides. A double whose magnitude reaches 1e32 is not supported
189
+ and fails loudly on DuckDB.
190
+ - **Wide tables are fine.** PostgreSQL caps a function call at 100 arguments,
191
+ so the row concatenation is built as a nested tree; tested to 500 columns.
192
+ - **Supported types:** integer, decimal, float, boolean, string, date,
193
+ timestamp. Anything else is compared as raw text and reported as a warning —
194
+ two engines may render the same JSON or array differently for reasons that
195
+ have nothing to do with the data.
196
+ - **At most 10,000 differences are reported by default.** Each one costs about
197
+ 715 bytes, so two tables that share nothing would need gigabytes rather than
198
+ producing an answer - and pointing the tool at the wrong table or the wrong
199
+ environment is exactly what it exists to catch. Past the limit the run is
200
+ flagged `truncated`, `identical` is never true, and the output says "at
201
+ least N". It does not mean the rest matched. `--max-diffs 0` lifts the limit.
202
+ - **Timezone-aware timestamps are compared as instants, in UTC.** Both
203
+ sessions are pinned to UTC on connect, so the same instant matches whatever
204
+ the two servers' default timezones are. Without that pin, two sides in
205
+ different zones render every `timestamptz` differently and the tool reports
206
+ the entire table as changed.
207
+ - Columns present on only one side are skipped with a warning, not treated as
208
+ differences.
209
+
210
+ ## Supported engines
211
+
212
+ | Engine | Status |
213
+ |---|---|
214
+ | PostgreSQL | supported (tested against 16 and 18) |
215
+ | DuckDB | supported (tested against 1.5) |
216
+ | Snowflake, BigQuery | not yet — see `CONTRIBUTING.md`, a dialect is ~80 lines |
217
+
218
+ ## Scope
219
+
220
+ In scope: proving two tables match, finding exactly which rows and columns
221
+ don't, doing it cheaply on large tables, running in CI.
222
+
223
+ Deliberately out of scope: data quality rules, freshness checks, anomaly
224
+ detection, lineage, cataloguing, orchestration, transformation, a web UI, a
225
+ server, schema migration. If a feature does not help someone answer *"can I
226
+ safely switch off the old system?"*, it does not belong here.
227
+
228
+ ## Development
229
+
230
+ ```bash
231
+ python -m venv .venv
232
+ .venv/Scripts/Activate.ps1 # Windows; source .venv/bin/activate elsewhere
233
+ pip install -e ".[all]" pytest
234
+ pytest
235
+ ```
236
+
237
+ Tests that need PostgreSQL read `PARITY_TEST_PG` (default
238
+ `postgres://parity:parity@127.0.0.1:5432/parity`) and **skip cleanly** when no
239
+ server is reachable — the DuckDB and pure-Python suites still run.
240
+
241
+ A disposable PostgreSQL:
242
+
243
+ ```bash
244
+ docker run -d --name parity-pg -p 55432:5432 \
245
+ -e POSTGRES_USER=parity -e POSTGRES_PASSWORD=parity -e POSTGRES_DB=parity \
246
+ postgres:16-alpine
247
+ export PARITY_TEST_PG="postgres://parity:parity@127.0.0.1:55432/parity"
248
+ ```
249
+
250
+ Reproduce the benchmark:
251
+
252
+ ```bash
253
+ python demo/generate.py --rows 10000000
254
+ python demo/benchmark.py --expect-clean
255
+ python demo/generate.py --rows 10000000 --plant
256
+ python demo/benchmark.py --expect-planted
257
+ ```
258
+
259
+ `CLAUDE.md` holds the verified cross-engine SQL and why each expression is the
260
+ way it is. `BUILD_SPEC.md` is the build plan.
261
+
262
+ ## Changelog
263
+
264
+ See `CHANGELOG.md`.
265
+
266
+ ## License
267
+
268
+ MIT — see `LICENSE`.
@@ -0,0 +1,233 @@
1
+ # parity
2
+
3
+ [![tests](https://github.com/Aleixiou/parity/actions/workflows/tests.yml/badge.svg)](https://github.com/Aleixiou/parity/actions/workflows/tests.yml)
4
+
5
+ **Prove two tables in two different database engines hold the same data —
6
+ without moving the data out of either engine.**
7
+
8
+ Migrations don't fail on translation. They fail at cutover, because nobody can
9
+ prove the new pipeline produces the same data as the old one — so the legacy
10
+ system runs in parallel "just to be safe", forever, at double the cost.
11
+ `parity` is the proof.
12
+
13
+ ```bash
14
+ parity diff \
15
+ --a "postgres://user:pw@legacy-host/warehouse" --a-table public.orders \
16
+ --b "duckdb:///./new.duckdb" --b-table main.orders \
17
+ --key order_id
18
+ ```
19
+
20
+ ```
21
+ ✗ 5 differences in 10,000,000 rows
22
+ 28 queries · 7,628 rows downloaded (0.04% of both tables) · 54.2s
23
+ 1 only in A · 1 only in B · 3 different
24
+
25
+ only in A key 999999999
26
+ only in B key 4000
27
+ different key 13 columns: note
28
+ note A '' B NULL
29
+ different key 6010000 columns: amount
30
+ amount A 700.010000 B 700.000000
31
+ different key 8700000 columns: is_refunded
32
+ is_refunded A NULL B false
33
+
34
+ comparing floats and decimals at 6 decimal places
35
+ ```
36
+
37
+ Exit code `1`. Put it in CI and the build fails until the data agrees.
38
+
39
+ ## How it works
40
+
41
+ `parity` pushes hash aggregation **down into both engines**. It asks each side
42
+ for one checksum per bucket of the key range, compares a handful of integers,
43
+ recurses only into the buckets that disagree, and downloads rows only from
44
+ ranges already proven to differ.
45
+
46
+ On identical tables it downloads **zero rows** and issues **four queries**,
47
+ whether the table has ten thousand rows or ten million.
48
+
49
+ ## Measured
50
+
51
+ 10,000,000 rows per side, PostgreSQL 18.4 ↔ DuckDB 1.5.5, median of five runs
52
+ on one developer laptop (`demo/benchmark.py`):
53
+
54
+ | Scenario | Queries | Rows downloaded | Wall time |
55
+ |---|---|---|---|
56
+ | identical tables | 4 | **0** (0.0000%) | 26.9s |
57
+ | 5 planted differences | 28 | 7,628 (0.0381%) | 54.2s |
58
+
59
+ The query count and the rows-downloaded figures are **exact and
60
+ hardware-independent** — they are properties of the algorithm, and the test
61
+ suite pins them. The wall times are one machine under sustained load; treat
62
+ them as an order of magnitude, not a specification.
63
+
64
+ The five planted differences — a changed decimal, a deleted row, an inserted
65
+ row at key 999,999,999, a `NULL` turned into `''`, and a `FALSE` turned into
66
+ `NULL` — are all found exactly, with no false positives.
67
+
68
+ Cost is roughly **one full hash pass per side**. It is CPU-bound on MD5, not
69
+ IO-bound on key lookup, which is why **an index on the key column makes no
70
+ measurable difference** (measured at 10M: 38.8s without, 37.3s with).
71
+ Raising
72
+ `--bisection-factor` does not speed up the dominant first pass; it only reduces
73
+ round trips on later levels.
74
+
75
+ ## Install
76
+
77
+ ```bash
78
+ pip install "parity-diff[all]" # both engines
79
+ pip install "parity-diff[duckdb]" # DuckDB only — no PostgreSQL driver pulled in
80
+ pip install "parity-diff[postgres]" # PostgreSQL only
81
+ ```
82
+
83
+ > The PyPI distribution is `parity-diff` — plain `parity` is squatted by an
84
+ > empty project. The command you run and the module you import are both
85
+ > `parity`.
86
+
87
+ Python 3.10+. The core has no dependencies; drivers are optional extras and are
88
+ imported lazily, so a DuckDB-only user is never made to install `psycopg`.
89
+
90
+ ## Usage
91
+
92
+ ```
93
+ parity diff --a CONN --a-table TABLE --b CONN --b-table TABLE --key COL
94
+ [--columns a,b,c] [--exclude x,y]
95
+ [--bisection-factor 32] [--threshold 10000] [--float-scale 6]
96
+ [--max-diffs 100] [--json] [--quiet]
97
+ ```
98
+
99
+ **Exit codes** — this is what makes it a CI check:
100
+
101
+ | Code | Meaning |
102
+ |---|---|
103
+ | `0` | identical |
104
+ | `1` | differences found |
105
+ | `2` | error (bad connection string, missing table, unusable key) |
106
+
107
+ An error is never `1`. A CI job can always tell "the tables differ" from "the
108
+ tool could not run".
109
+
110
+ Connection strings:
111
+
112
+ ```
113
+ postgres://user:password@host:port/database (also postgresql://)
114
+ duckdb:///relative/path.duckdb (three slashes = relative)
115
+ duckdb:////var/lib/warehouse.duckdb (four slashes = absolute)
116
+ duckdb:///C:/data/warehouse.duckdb (absolute, Windows)
117
+ duckdb:///:memory:
118
+ ```
119
+
120
+ Table names may be schema-qualified. Unqualified names default to `public` on
121
+ PostgreSQL and `main` on DuckDB.
122
+
123
+ `--json` emits the same content as a machine-readable object, including
124
+ `identical`, `truncated`, per-difference values, and the full stats block.
125
+
126
+ ### In CI
127
+
128
+ ```yaml
129
+ - name: prove the migration is complete
130
+ run: |
131
+ parity diff \
132
+ --a "$LEGACY_URL" --a-table public.orders \
133
+ --b "$WAREHOUSE_URL" --b-table analytics.orders \
134
+ --key order_id --quiet
135
+ ```
136
+
137
+ ## Limitations — read these before trusting a result
138
+
139
+ A parity tool that reports a false match is worse than useless, so these are
140
+ stated plainly rather than buried.
141
+
142
+ - **Integer keys only.** The bisection arithmetic divides the key range. A
143
+ `varchar` or `uuid` key is rejected with a clear message, not guessed at.
144
+ Composite and hashed keys are a planned extension.
145
+ - **Floats and decimals are compared at 6 decimal places** by default. Two
146
+ values differing only in the 7th place are reported as *equal*. This is a
147
+ deliberate cross-engine rounding contract — the two engines do not otherwise
148
+ agree on float text. Change it with `--float-scale`; it always applies to
149
+ both sides, and the scale in force is printed on every run.
150
+ - **Keys must be unique.** A non-unique key is detected up front and rejected.
151
+ Silently collapsing duplicate rows would hide real differences.
152
+ - **`Infinity` and `NaN`** in float columns are compared as those literal
153
+ tokens on both sides. A double whose magnitude reaches 1e32 is not supported
154
+ and fails loudly on DuckDB.
155
+ - **Wide tables are fine.** PostgreSQL caps a function call at 100 arguments,
156
+ so the row concatenation is built as a nested tree; tested to 500 columns.
157
+ - **Supported types:** integer, decimal, float, boolean, string, date,
158
+ timestamp. Anything else is compared as raw text and reported as a warning —
159
+ two engines may render the same JSON or array differently for reasons that
160
+ have nothing to do with the data.
161
+ - **At most 10,000 differences are reported by default.** Each one costs about
162
+ 715 bytes, so two tables that share nothing would need gigabytes rather than
163
+ producing an answer - and pointing the tool at the wrong table or the wrong
164
+ environment is exactly what it exists to catch. Past the limit the run is
165
+ flagged `truncated`, `identical` is never true, and the output says "at
166
+ least N". It does not mean the rest matched. `--max-diffs 0` lifts the limit.
167
+ - **Timezone-aware timestamps are compared as instants, in UTC.** Both
168
+ sessions are pinned to UTC on connect, so the same instant matches whatever
169
+ the two servers' default timezones are. Without that pin, two sides in
170
+ different zones render every `timestamptz` differently and the tool reports
171
+ the entire table as changed.
172
+ - Columns present on only one side are skipped with a warning, not treated as
173
+ differences.
174
+
175
+ ## Supported engines
176
+
177
+ | Engine | Status |
178
+ |---|---|
179
+ | PostgreSQL | supported (tested against 16 and 18) |
180
+ | DuckDB | supported (tested against 1.5) |
181
+ | Snowflake, BigQuery | not yet — see `CONTRIBUTING.md`, a dialect is ~80 lines |
182
+
183
+ ## Scope
184
+
185
+ In scope: proving two tables match, finding exactly which rows and columns
186
+ don't, doing it cheaply on large tables, running in CI.
187
+
188
+ Deliberately out of scope: data quality rules, freshness checks, anomaly
189
+ detection, lineage, cataloguing, orchestration, transformation, a web UI, a
190
+ server, schema migration. If a feature does not help someone answer *"can I
191
+ safely switch off the old system?"*, it does not belong here.
192
+
193
+ ## Development
194
+
195
+ ```bash
196
+ python -m venv .venv
197
+ .venv/Scripts/Activate.ps1 # Windows; source .venv/bin/activate elsewhere
198
+ pip install -e ".[all]" pytest
199
+ pytest
200
+ ```
201
+
202
+ Tests that need PostgreSQL read `PARITY_TEST_PG` (default
203
+ `postgres://parity:parity@127.0.0.1:5432/parity`) and **skip cleanly** when no
204
+ server is reachable — the DuckDB and pure-Python suites still run.
205
+
206
+ A disposable PostgreSQL:
207
+
208
+ ```bash
209
+ docker run -d --name parity-pg -p 55432:5432 \
210
+ -e POSTGRES_USER=parity -e POSTGRES_PASSWORD=parity -e POSTGRES_DB=parity \
211
+ postgres:16-alpine
212
+ export PARITY_TEST_PG="postgres://parity:parity@127.0.0.1:55432/parity"
213
+ ```
214
+
215
+ Reproduce the benchmark:
216
+
217
+ ```bash
218
+ python demo/generate.py --rows 10000000
219
+ python demo/benchmark.py --expect-clean
220
+ python demo/generate.py --rows 10000000 --plant
221
+ python demo/benchmark.py --expect-planted
222
+ ```
223
+
224
+ `CLAUDE.md` holds the verified cross-engine SQL and why each expression is the
225
+ way it is. `BUILD_SPEC.md` is the build plan.
226
+
227
+ ## Changelog
228
+
229
+ See `CHANGELOG.md`.
230
+
231
+ ## License
232
+
233
+ MIT — see `LICENSE`.