xoverrr (pronounced “crossover”)
A tool for cross-database and intra-source data quality checks with detailed discrepancy analysis and reporting.
Supported databases: Oracle, PostgreSQL (+ Greenplum), ClickHouse.
Features
- Four check strategies — row samples, daily counts, custom SQL, and source-only sniff checks
- Multi-DBMS — tables and views; extensible via adapters
- SQLAlchemy engines — pass any supported source / target / results connection
- Replication-lag aware — optionally skip “fresh” rows that may still be catching up
- Auto metadata — primary keys and column types from DBMS catalogs (or supply your own PK)
- Type conversion — application-side normalization across databases
- Column filters — include / exclude lists; mismatched column names skipped automatically
- Chunked date ranges — process long periods in N-day windows
- Reports — text or JSON, with example mismatched rows
- Optional persistence — write run results to a third engine for dashboards / audit
- Tests — unit coverage plus Docker-backed integration tests
Quick start
Sample check (Greenplum/PostgreSQL → Oracle):
from xoverrr import DataQualityChecker, DataReference, CHECK_SUCCESS
from sqlalchemy import create_engine
from datetime import date, timedelta
# 1. Connections
source_engine = create_engine('postgresql://user:pass@localhost:5432/source_db')
target_engine = create_engine('oracle+oracledb://user:pass@localhost:1521/target_db')
results_engine = create_engine('postgresql://user:pass@localhost:5432/dq_audit')
# 2. Checker
checker = DataQualityChecker(
source_engine=source_engine,
target_engine=target_engine,
timezone='Europe/Athens',
results_engine=results_engine, # optional
)
# 3. Tables + date window
source_table = DataReference("employees", schema="hr")
target_table = DataReference("employees", schema="hr")
end_date = date.today()
start_date = end_date - timedelta(days=7)
# 4. Run
status, report, stats, details = checker.check_samples(
source_table=source_table,
target_table=target_table,
date_column="hire_date",
update_column="modified_at",
date_range=(start_date.strftime('%Y-%m-%d'), end_date.strftime('%Y-%m-%d')),
chunk_size_days=30,
custom_primary_key=["employee_id"],
exclude_columns=["audit_log", "temp_field"],
tolerance_pct=0.5,
exclude_recent_hours=3,
max_examples=5,
persist_result=DataReference("dq_results", "test"),
check_name="employees_daily",
check_tags={"env": "prod", "domain": "hr"},
report_output_format='text', # 'json' or 'text'
)
# 5. Result
print(report)
if status == CHECK_SUCCESS:
print("Data quality check passed")
else:
print("Data quality check failed")
Every check method returns the same tuple:
| Value | Meaning |
|---|---|
status |
CHECK_SUCCESS / CHECK_FAILED / CHECK_SKIPPED |
report |
Text report or JSON string (report_output_format) |
stats |
CheckStats — scores and row counts |
details |
CheckDetails — examples and per-column diffs |
Which method should I use?
| Method | When to use | Needs target DB? |
|---|---|---|
check_samples |
Compare row values between two tables/views | Yes |
check_counts |
Fast volume check by day (missing / extra rows) | Yes |
check_custom_queries |
Complex joins, renamed columns, custom SQL | Yes |
check_sniff_query |
Source-only rule: “does this data look wrong?” | No |
Check methods
1. Data sample (check_samples)
Compares row sets and column values over a date range.
status, report, stats, details = checker.check_samples(
source_table=DataReference("table_name", "schema_name"),
target_table=DataReference("table_name", "schema_name"),
date_column="created_at",
update_column="modified_date",
date_range=("2024-01-01", "2024-12-31"),
chunk_size_days=30,
exclude_columns=["audit_timestamp", "internal_id"],
include_columns=None,
custom_primary_key=["id", "user_id"],
tolerance_pct=1.0,
exclude_recent_hours=24,
max_examples=3,
)
Main parameters
| Parameter | Description |
|---|---|
source_table, target_table |
Tables or views to compare |
date_column |
Column for date-range filtering |
update_column |
Marks “fresh” rows (excluded on both sides) |
date_range |
(start_date, end_date) as YYYY-MM-DD |
chunk_size_days |
Optional N-day windows over the range |
exclude_columns / include_columns |
Blacklist / whitelist of columns |
custom_primary_key |
PK columns; auto-detected if omitted |
tolerance_pct |
Fail if final_diff_score exceeds this (0–100) |
exclude_recent_hours |
Drop rows modified in the last N hours |
max_examples |
Cap on discrepancy examples in the report |
persist_result |
False, True (default table), or DataReference |
check_name / check_tags |
Labels for dashboards |
report_output_format |
'text' (default) or 'json' |
If custom_primary_key is omitted, the PK is inferred from metadata (must exist on at least one side).
2. Counts (check_counts)
Daily aggregates — good for large volumes and spotting missing/extra rows.
status, report, stats, details = checker.check_counts(
source_table=DataReference("users", "schema1"),
target_table=DataReference("users", "schema2"),
date_column="created_at",
date_range=("2024-01-01", "2024-12-31"),
chunk_size_days=30,
tolerance_pct=2.0,
max_examples=5,
)
Main parameters: source_table, target_table, date_column, date_range, chunk_size_days, tolerance_pct, max_examples, plus the shared persist_result / check_name / check_tags / report_output_format options described above.
3. Custom query (check_custom_queries)
Compare arbitrary SQL on both sides. Primary key is required.
status, report, stats, details = checker.check_custom_queries(
source_query="""
SELECT id AS user_id, name AS user_name, created_at AS created_date
FROM scott.source_table
WHERE status = :status
""",
source_params={'status': 'active'},
target_query="""
SELECT user_id, user_name, created_date
FROM scott.target_table
WHERE status = :status
""",
target_params={'status': 'active'},
custom_primary_key=["user_id"],
exclude_columns=["internal_code"],
tolerance_pct=0.5,
max_examples=3,
)
Chunking: when both source_params and target_params include start_date / end_date, set chunk_size_days to split the range:
status, report, stats, details = checker.check_custom_queries(
source_query="""
SELECT id, name, created_at
FROM scott.source_table
WHERE created_at >= date_trunc('day', cast(:start_date as date))
AND created_at < date_trunc('day', cast(:end_date as date)) + interval '1 day'
""",
source_params={'start_date': '2024-01-01', 'end_date': '2024-12-31'},
target_query="""
SELECT id, name, created_at
FROM scott.target_table
WHERE created_at >= date_trunc('day', cast(:start_date as date))
AND created_at < date_trunc('day', cast(:end_date as date)) + interval '1 day'
""",
target_params={'start_date': '2024-01-01', 'end_date': '2024-12-31'},
custom_primary_key=["id"],
chunk_size_days=30,
tolerance_pct=0.5,
)
To skip recently changed rows in custom SQL, add the same flag used by sample check:
CASE WHEN updated_at > (sysdate - 3/24) THEN 'y' END AS xrecently_changed
4. Sniff query (check_sniff_query)
Source-only check. Mark each row with xsniff_passed (y = passed, n = failed).
No target engine or primary key required:
checker = DataQualityChecker(
source_engine=source_engine,
timezone='UTC',
)
Row-level — one flag per row:
status, report, stats, details = checker.check_sniff_query(
source_query="""
SELECT
order_id,
amount,
CASE
WHEN amount > 0 AND customer_id IS NOT NULL THEN 'y'
ELSE 'n'
END AS xsniff_passed
FROM sales.orders
WHERE created_at >= :start_date
""",
source_params={'start_date': '2024-01-01'},
tolerance_pct=1.0,
)
Scalar pass/fail — a single xsniff_passed value:
status, report, stats, details = checker.check_sniff_query(
source_query="""
SELECT CASE
WHEN EXISTS (SELECT 1 FROM sales.orders WHERE amount <= 0) THEN 'n'
ELSE 'y'
END AS xsniff_passed
""",
tolerance_pct=0.0,
)
Issues-only filter — WHERE keeps only bad rows and marks every returned row with a literal 'n'.
Empty result means pass (final_score = 100). Any returned row means fail (issue_rows_pct = 100 for that result set):
status, report, stats, details = checker.check_sniff_query(
source_query="""
SELECT
order_id,
amount,
'n' AS xsniff_passed
FROM sales.orders
WHERE amount <= 0
AND created_at >= :start_date
""",
source_params={'start_date': '2024-01-01'},
tolerance_pct=0.0,
)
Use this when you only care that issue rows exist (and want their keys/attributes in the report). Prefer the row-level CASE pattern above when you need a rate over the full checked scope.
Main parameters: source_query, source_params, chunk_size_days (when params include dates), tolerance_pct, max_examples, plus shared persistence / naming / report format options.
Useful stats fields:
| Field | Meaning |
|---|---|
total_source_rows |
Rows checked |
passed_rows |
Passed (xsniff_passed = y) |
issue_rows_pct |
Issue rows % (xsniff_passed = n) |
final_diff_score |
Same as issue_rows_pct |
Metric calculation
All methods expose stats.final_diff_score and stats.final_score.
Quality score (every method):
final_score = 100 − final_diff_score
Scores are 0–100%. Higher final_score = better quality.
Pass/fail uses tolerance:
final_diff_score > tolerance_pct→CHECK_FAILED- otherwise →
CHECK_SUCCESS
check_samples / check_custom_queries
final_diff_score =
(dup_source_rows_pct × 0.1)
+ (dup_target_rows_pct × 0.1)
+ (source_only_rows_pct × 0.15)
+ (target_only_rows_pct × 0.15)
+ (issue_rows_pct × 0.5)
check_counts
sum_of_absolute_differences = abs(source_count − target_count) per day
sum_of_common_counts = min(source_count, target_count) per day
final_diff_score = 100 × sum_of_absolute_differences
/ (sum_of_absolute_differences + sum_of_common_counts)
check_sniff_query
issue_rows_pct = (rows with xsniff_passed = 'n') / (checked rows) × 100
final_diff_score = issue_rows_pct
Empty result → final_diff_score = 0 (and score 100).
With the issues-only filter pattern (WHERE … + literal 'n' AS xsniff_passed), checked rows are only the filtered issue rows, so any non-empty result yields issue_rows_pct = 100.
Shared behaviour
Chunked processing (chunk_size_days)
Available on all methods. Splits a date range into N-day windows, runs each chunk, then aggregates metrics and examples. Useful for long ranges or large tables.
check_custom_queries: both sides must passstart_dateandend_datein paramscheck_sniff_query: chunking usesstart_date/end_dateinsource_params
Status values
| Status | Meaning |
|---|---|
CHECK_SUCCESS |
Within tolerance |
CHECK_FAILED |
Over tolerance, or a technical error |
CHECK_SKIPPED |
Nothing to compare (e.g. both sides empty) |
Result persistence
With results_engine set and persist_result=True (or a custom DataReference), one row is written per run. The table is created if missing; primary key is run_id. Columns cover status, metadata, stats, details JSON, and the text report.
Logging
Each run has an internal run_id (also stored when persistence is on; not in public JSON from CheckResult.to_dict()):
2024-01-15 10:30:45 - INFO - xoverrr.core - Check run started: run_id=a3f2c8b91d4e5678 check_name=employees_daily check_type=samples
2024-01-15 10:30:45 - INFO - xoverrr.core._check_samples - Query executed in 2.34s
2024-01-15 10:30:46 - INFO - xoverrr.core._check_samples - Source: 150000 rows, Target: 149950 rows
2024-01-15 10:30:47 - INFO - xoverrr.utils.compare_dataframes - Comparison completed in 1.2s
2024-01-15 10:30:47 - INFO - xoverrr.core - Check run finished: run_id=a3f2c8b91d4e5678 status=CHECK_SUCCESS
Performance notes
- DataFrame size hard limit: 3 GB per sample
- Rough benchmark: two samples of ~1M rows × 10 columns (~330 MB each) compared in ~3 s (Intel Core i5 / 16 GB RAM)
Example report
Text output (report_output_format='text'):
================================================================================
2025-11-24 20:09:40
run_id: a3f2c8b91d4e5678
version: *.*.*
source db type: postgresql
target db type: oracle
SAMPLES CHECK REPORT:
hr.employees
VS
hr.employees
================================================================================
timezone: Europe/Athens
SELECT employee_id, first_name, last_name, salary, department_id, hire_date,
case when updated_at > (now() - INTERVAL '3 hours') then 'y' end as xrecently_changed
FROM hr.employees
WHERE 1=1
AND hire_date >= date_trunc('day', cast(:start_date as date))
AND hire_date < date_trunc('day', cast(:end_date as date)) + interval '1 day'
params: {'start_date': '2025-11-17', 'end_date': '2025-11-24'}
----------------------------------------
SELECT employee_id, first_name, last_name, salary, department_id, hire_date,
case when updated_at > (sysdate - 3/24) then 'y' end as xrecently_changed
FROM hr.employees
WHERE 1=1
AND hire_date >= trunc(to_date(:start_date, 'YYYY-MM-DD'), 'dd')
AND hire_date < trunc(to_date(:end_date, 'YYYY-MM-DD'), 'dd') + 1
params: {'start_date': '2025-11-17', 'end_date': '2025-11-24'}
----------------------------------------
SUMMARY:
Source rows: 105
Target rows: 105
Duplicated source rows: 0
Duplicated target rows: 0
Only source rows: 0
Only target rows: 0
Comparable rows: 105
Passed rows: 103
----------------------------------------
Source only rows %: 0.00000
Target only rows %: 0.00000
Duplicated source rows %: 0.00000
Duplicated target rows %: 0.00000
Issue rows %: 1.90476
Final discrepancies score: 0.95238
Final data quality score: 99.04762
Source-only key examples:
Target-only key examples:
Duplicated source key examples:
Duplicated target key examples:
Evaluated columns: first_name, last_name, salary, department_id
Skipped source columns: audit_log, temp_field
Skipped target columns:
ISSUE BREAKDOWN:
Max issue %: 1.90476
Issue counts by column:
column_name issue_count
salary 2
Issue examples:
primary_key column_name source_value target_value
101 salary 50000 51000
102 salary 60000 60500
================================================================================
Known limitations
Oracle thin client & TIMESTAMP WITH TIME ZONE
With the Oracle thin client and check_custom_queries, TIMESTAMP WITH TIME ZONE columns lose timezone context in the result set.
Workaround — cast to TIMESTAMP in SQL:
source_query = """
SELECT
order_id,
CAST(created_at AT TIME ZONE 'Europe/Paris' AS TIMESTAMP) AS created_at,
amount
FROM orders
WHERE status = 'completed'
"""
Metadata
Release files for xoverrr 1.4.2
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| xoverrr-1.4.2.tar.gz | 47.0 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| xoverrr-1.4.2-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 96.9 kB
Release files / xoverrr-1.4.2.tar.gz
| Download URL | xoverrr-1.4.2.tar.gz |
|---|---|
| Size | 47.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
4582ff476c47c2b65ed0948765872ba462cd916055d21aa0a70db2ca675c0bae
|
|
BLAKE2b-256 checksum How to use checksums |
b3df5901b9e92781c385fd1ec5e76481dbeeb60017d6a12764171e43d0291fec
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.7
|
Release files / xoverrr-1.4.2-py3-none-any.whl
| Download URL | xoverrr-1.4.2-py3-none-any.whl |
|---|---|
| Size | 49.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
1aba900d3d9b538c64d8cbb42b4bdd3f0b02c93c84f92e165bbdf4b818c28539
|
|
BLAKE2b-256 checksum How to use checksums |
d9a77f30f341e6c88470e79178e5827204bf7420e4505c3eca3f08e8b407ceee
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.7
|