Skip to main content

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 pass start_date and end_date in params
  • check_sniff_query: chunking uses start_date / end_date in source_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)

Source distribution for xoverrr 1.4.2
File Size Uploaded
xoverrr-1.4.2.tar.gz 47.0 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for xoverrr 1.4.2
File Interpreter ABI Platform
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

Release history Release notifications | RSS feed

1.4.8

2 release files

1.4.7

2 release files

1.4.6

2 release files

1.4.5

2 release files

1.4.4

2 release files

1.4.3

2 release files

This release

1.4.2 This release

2 release files

1.4.1

2 release files

1.3.2

2 release files

1.3.1

2 release files

1.3.0

2 release files

1.2.5

2 release files

1.2.4

2 release files

1.2.3

2 release files

1.2.2

2 release files

1.2.1

2 release files

1.2.0

2 release files

1.1.9

2 release files

1.1.8

2 release files

1.1.7

2 release files

1.1.6

2 release files

1.1.5

2 release files

1.1.4

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page