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

  • Five check strategies - row samples, counts grouped by date, whole-table counts, custom SQL, and source-only sniff checks
  • Multi-DBMS - tables and views, extensible via adapters
  • SQLAlchemy engines - pass any supported source, target, or results connection
  • Recent-row exclusion - optionally skip rows that may still be delayed (batch load, replication, or calculation)
  • Auto metadata - primary keys and column types from DBMS catalogues (or supply your own primary key)
  • Type conversion - application-side normalisation across databases
  • Column filters - include / exclude lists; mismatched column names are 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 and audit
  • Tests - unit coverage plus Docker-backed integration tests

Quick start

Sample check (Greenplum/PostgreSQL -> Oracle):

from xoverrr import DataQualityChecker, DataReference, CheckResult, 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
    max_dataframe_size_gb=3,  # per query / chunk from one DB
)

# 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
result: CheckResult = 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(result.run_id)
print(result.report)
if result.status == CHECK_SUCCESS:
    print("Data quality check passed")
else:
    print("Data quality check failed")

Every check method returns a CheckResult:

result = checker.check_samples(...)
result.run_id
result.status
result.report
result.stats
result.details
Field Meaning
result.status CHECK_SUCCESS / CHECK_FAILED / CHECK_SKIPPED
result.report Text report or JSON string (report_output_format)
result.stats CheckStats - scores and row counts
result.details CheckDetails - examples and per-column diffs
result.run_id Unique id of this run (also in JSON and persistence)

Which method should I use?

Method When to use Requires a target database?
check_samples Compare row values between two tables/views Yes
check_counts_group_by_date Volume grouped by a date/timestamp column (missing / extra rows) Yes
check_total_counts Whole-table COUNT(*), optional date-window chunks Yes
check_custom_queries Complex joins, renamed columns, custom SQL Yes
check_sniff_query Source-only rule: "does this data look wrong?" No

A target engine is required for the first four methods, but it may be the same as source_engine (two tables or schemas in one database). Pass a second engine only when the sides live in different databases.


Check methods

1. Data sample (check_samples)

This method compares row sets and column values over a date range.

result = 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 Timestamp used to detect recently changed rows (excluded on both sides)
date_range (start_date, end_date) as YYYY-MM-DD; either bound may be omitted unless chunking
chunk_size_days Optional N-day windows; requires both date_range bounds
exclude_columns / include_columns A blacklist or whitelist of columns
custom_primary_key Primary-key columns; detected automatically if omitted
tolerance_pct The check fails if final_diff_score exceeds this (0-100)
exclude_recent_hours Exclude rows changed in the last N hours (batch load, replication, or calculation delay)
max_examples Maximum number of discrepancy examples in the report
persist_result DataReference of the results table; omit this option to skip persistence
check_name / check_tags Labels for dashboards
report_output_format 'text' (default) or 'json'

If custom_primary_key is omitted, the primary key is inferred from metadata (it must exist on at least one side).


2. Counts grouped by date (check_counts_group_by_date)

Compares row counts grouped by a date or timestamp column. Currently only daily aggregates are supported (hour / month / year grouping is not). Suitable for large volumes and for spotting missing or extra rows.

result = checker.check_counts_group_by_date(
    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. One-sided date_range is allowed without chunking; chunk_size_days requires both start and end dates.


3. Total counts (check_total_counts)

Whole-table COUNT(*) on each side. Optional date_range / chunk_size_days split the scan into date windows and sum the counts (no per-date breakdown).

result = checker.check_total_counts(
    source_table=DataReference("users", "schema1"),
    target_table=DataReference("users", "schema2"),
    tolerance_pct=2.0,
)

result = checker.check_total_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,
)

Main parameters: source_table, target_table, date_column, date_range, chunk_size_days, tolerance_pct, plus the shared persist_result / check_name / check_tags / report_output_format options. date_range and chunk_size_days require date_column. One-sided date_range is allowed without chunking; chunk_size_days requires both start and end dates.


4. Custom query (check_custom_queries)

Compare the results of arbitrary SQL on both sides. A primary key is required.

result = 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 and end_date, set chunk_size_days to split the range:

result = 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 as that used by the sample check:

CASE WHEN updated_at > (sysdate - 3/24) THEN 'y' END AS xrecently_changed

5. Sniff query (check_sniff_query)

A source-only check. Mark each row with xsniff_passed (y = passed, n = failed).
No target engine or primary key is required:

checker = DataQualityChecker(
    source_engine=source_engine,
    timezone='UTC',
)

Row-level - one flag per row. Use this when you need an issue rate over the full scope; tolerance_pct then means "allow up to N% of rows with xsniff_passed = n":

result = 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. The outcome is typically binary (final_diff_score 0 or 100), so leave tolerance_pct at the default 0.0 (fail on any issue):

result = 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
    """,
)

Issues-only filter - WHERE keeps only the failing rows and marks every returned row with a literal 'n'.
An empty result means a pass (final_score = 100). Any returned row means a fail (issue_rows_pct = 100 for that result set). As with the scalar pattern, keep the default tolerance_pct=0.0 so that the check fails if any issue is found:

result = 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'},
)

Use this when you need only to establish that issue rows exist (and to include their keys and 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 the parameters include dates), tolerance_pct, max_examples, plus the shared persistence, naming, and 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 result.stats.final_diff_score and result.stats.final_score.

Quality score (every method):

final_score = 100 - final_diff_score

Scores range from 0 to 100%. A higher final_score indicates better quality.
Pass or fail is determined by the 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_group_by_date / check_total_counts

sum_of_absolute_differences = abs(source_count - target_count)  per date (or one total)
sum_of_common_counts        = min(source_count, target_count)   per date (or one total)

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

An empty result yields final_diff_score = 0 (and a score of 100).

With the issues-only filter pattern (WHERE ... plus a 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 check_samples, check_counts_group_by_date, check_total_counts, check_custom_queries, and check_sniff_query. It splits a date range into N-day windows, runs each chunk, then aggregates the metrics and examples. This is useful for long ranges or large tables. Chunking needs both start and end dates; without chunk_size_days, either bound may be omitted.

  • check_custom_queries: both sides must supply start_date and end_date in their parameters
  • check_sniff_query: chunking uses start_date / end_date in source_params
  • check_total_counts: date_column plus date_range; chunk COUNT(*) values are summed

Status values

Status Meaning
CHECK_SUCCESS Within tolerance
CHECK_FAILED Above tolerance, or a technical error
CHECK_SKIPPED Nothing to compare (e.g. both sides empty)

Result persistence

With results_engine set and persist_result=DataReference(...), one row is written per run to that table. The schema must already exist; the table is created if it is missing. The columns cover status, metadata, statistics, details JSON, and the text report. Persistence is skipped if persist_result is omitted. If persistence was requested and the write fails, the check status becomes failed.

Logging

Each run has a run_id on the returned CheckResult (also stored when persistence is enabled, and included in CheckResult.to_dict() / JSON reports):

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 limit: max_dataframe_size_gb on the checker (default 3 GB per query / chunk from one DB)
  • Rough benchmark: two samples of about 1 million rows * 10 columns (about 330 MB each) compared in about 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:
  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 their time-zone 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'
"""

CI/CD

GitHub Actions (see .github/workflows/):

Event What runs
Push to any branch Unit tests on Python 3.9 and 3.12
Push with [integration] in the commit message Unit tests plus integration tests
Actions → CI / Integration tests → Run workflow (after workflows are on main) Manual run on the chosen branch
Tag vX.Y.Z whose commit is on main All tests, then publish to PyPI

The tag must match the version in pyproject.toml and src/xoverrr/version.py (for example tag v1.4.6). Release details and the one-time PyPI Trusted Publishing setup are in COTRIBUTE.md.

Local commands used by CI are in the Makefile (make test-unit, make up-dbs, make build).

Metadata

Release files for xoverrr 1.4.6

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.6
File Size Uploaded
xoverrr-1.4.6.tar.gz 50.8 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for xoverrr 1.4.6
File Interpreter ABI Platform
xoverrr-1.4.6-py3-none-any.whl Python 3 none any Details

Total release size: 103.4 kB

Release files / xoverrr-1.4.6.tar.gz

Download URL xoverrr-1.4.6.tar.gz
Size 50.8 kB
Tags Source
SHA-256 checksum
How to use checksums
eb8195f29b17e62825f518456ee3393cded83009ccfe6b87531f076c18f796e9
BLAKE2b-256 checksum
How to use checksums
2a6c29f0363553afdddc2089d10441f5949f31b9788036bfbf11b2f95abed577
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 19, 2026.

Transparency log

Release files / xoverrr-1.4.6-py3-none-any.whl

Download URL xoverrr-1.4.6-py3-none-any.whl
Size 52.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
e2aec46ad8bd6e20618f90c3d001f1e50414437bb1250a9990d01bee391b501f
BLAKE2b-256 checksum
How to use checksums
0578689e69166aacc7f49803195b516ad7a0d37646ba5a48a91952d2fa7e8664
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 19, 2026.

Transparency log

Release history Release notifications | RSS feed

1.4.8

2 release files

1.4.7

2 release files

This release

1.4.6 This release

2 release files

1.4.5

2 release files

1.4.4

2 release files

1.4.3

2 release files

1.4.2

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