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.
- parity_diff-0.1.0/CONTRIBUTING.md +149 -0
- parity_diff-0.1.0/LICENSE +21 -0
- parity_diff-0.1.0/MANIFEST.in +10 -0
- parity_diff-0.1.0/PKG-INFO +268 -0
- parity_diff-0.1.0/README.md +233 -0
- parity_diff-0.1.0/pyproject.toml +104 -0
- parity_diff-0.1.0/setup.cfg +4 -0
- parity_diff-0.1.0/src/parity/__init__.py +31 -0
- parity_diff-0.1.0/src/parity/cli.py +361 -0
- parity_diff-0.1.0/src/parity/dialects/__init__.py +0 -0
- parity_diff-0.1.0/src/parity/dialects/base.py +487 -0
- parity_diff-0.1.0/src/parity/dialects/duckdb_dialect.py +132 -0
- parity_diff-0.1.0/src/parity/dialects/postgres_dialect.py +139 -0
- parity_diff-0.1.0/src/parity/engine.py +449 -0
- parity_diff-0.1.0/src/parity/types.py +125 -0
- parity_diff-0.1.0/src/parity_diff.egg-info/PKG-INFO +268 -0
- parity_diff-0.1.0/src/parity_diff.egg-info/SOURCES.txt +25 -0
- parity_diff-0.1.0/src/parity_diff.egg-info/dependency_links.txt +1 -0
- parity_diff-0.1.0/src/parity_diff.egg-info/entry_points.txt +2 -0
- parity_diff-0.1.0/src/parity_diff.egg-info/requires.txt +10 -0
- parity_diff-0.1.0/src/parity_diff.egg-info/top_level.txt +1 -0
- parity_diff-0.1.0/tests/conftest.py +79 -0
- parity_diff-0.1.0/tests/fakes.py +251 -0
- parity_diff-0.1.0/tests/test_cli.py +558 -0
- parity_diff-0.1.0/tests/test_encoding.py +1280 -0
- parity_diff-0.1.0/tests/test_engine.py +743 -0
- parity_diff-0.1.0/tests/test_integration.py +528 -0
|
@@ -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
|
+
[](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
|
+
[](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`.
|