Skip to main content

dference

PyPI Python versions Downloads CI License: ISC
uv Ruff ty Open the demo in molab

Compare two DataFrames by key and explore every difference – in an interactive widget for marimo and Jupyter, or as plain polars tables.

Rows are matched on a key (one or several columns) and classified as

Status Meaning
equal key on both sides, all compared values equal
mismatch key on both sides, at least one value differs
missing_right key only in the left frame (shown as Only in {left_name})
missing_left key only in the right frame (shown as Only in {right_name})

The widget is modelled on marimo.ui.table: paging, sorting, search, a status filter, marimo-style column filters, per-column match rates, a side-by-side detail view, invisible-character highlighting and CSV export. It stays fast on millions of rows because filtering, sorting and paging run in polars on the Python side – the browser only ever receives the visible page.

Installation

uv add dference            # or: pip install dference
uv add "dference[pandas]"  # if your inputs are pandas DataFrames

dference depends on polars, anywidget and traitlets. Inputs can be polars (eager or lazy), pandas, pyarrow, or anything implementing the Arrow PyCapsule interface or to_polars() (DuckDB relations, narwhals, …).

Quick start

marimo

import marimo as mo
from dference import DataFrameDiff

diff = DataFrameDiff(crm, erp, key="customer_id", left_name="CRM", right_name="ERP")
view = mo.ui.anywidget(diff)
view

In another cell, work with the rows checked in the widget. The cell re-runs when the selection changes – and only then; paging, sorting and filtering never trigger re-execution:

view.value["selected_ids"]  # reactive dependency
diff.selected_frame()  # the checked rows as a polars DataFrame

Jupyter

from dference import DataFrameDiff

DataFrameDiff(crm, erp, key=["region", "customer_id"])

Without a UI

import dference

result = dference.compare(crm, erp, key="customer_id", left_name="CRM", right_name="ERP")

result.summary  # Summary(equal=158, mismatch=78, missing_left=10, missing_right=8, ...)
result.frame()  # wide: key, status, differing, "<col> [CRM]", "<col> [ERP]", ...
result.frame("mismatch")
result.mismatches()  # long: one row per (key, column) that differs
result.column_stats()  # per column: dtypes, mismatches, equal/mismatch share

How values are compared

  • Exact equality. There is deliberately no numeric tolerance. If small float differences should not count, round both frames first, e.g. df.with_columns(cs.float().round(2)).
  • Missing values are equal to each other: null == null, and in float columns NaN counts as missing too (pandas uses NaN as its null marker, so a frame from pandas and one from Arrow would otherwise disagree).
  • Same column, same dtype. By default (strict=True) a column must have the identical dtype on both sides.
  • strict=False aligns differences that lose nothing – the same values stored differently: integers of different width, Float32 vs. Float64, decimals of different precision, Datetime / Duration in different time units (same time zone), text vs. Categorical / Enum, and an all-null column vs. anything. ColumnInfo.compare_dtype tells you which dtype was used.
  • Everything else is always your decision. Int vs. float, Date vs. Datetime, naive vs. time-zone aware, different time zones, text vs. numbers, different nested types: compare() raises one ValueError that lists every such column (keys included) with a cast to fix it, e.g. right = right.with_columns(pl.col("qty").cast(pl.Int64)). Note that pandas has no date dtype – a Date column from pandas arrives as a Datetime.
  • Keys must be unique on each side; null keys match null keys.
  • Columns present on one side only are shown but not compared. Use ignore_columns=[...] to leave columns out entirely.

Widget features

  • Status overview on a shared scale (equal / mismatch / only left / only right, plus found vs. not found); filter by status from the menu in the status column header.
  • Each side has one colour and one chip – the first letter of its name (left_short= / right_short= to override) – used everywhere: differences show both values with equal weight, each marked by its side's chip, and rows on one side only carry the chip of the side they exist on.
  • Per-column match rate in the header; click ≠ or = to show only rows that differ in, or are equal in, that column.
  • Column menu per header: sort and type-aware filters (text, number range, date range, boolean, null checks). For compared columns a row matches if the left or the right value matches; a side that does not exist in a row never matches.
  • Invisible characters are made visible: ␣ leading/trailing/repeated spaces, → tab, ↵ line feed, ⍽ no-break spaces, ZWSP, BOM, … Cells that differ only in invisible characters are flagged.
  • Side-by-side detail view with numeric deltas; step through the filtered rows across page boundaries.
  • CSV export of all rows matching the current filters.

Python API

Object Purpose
compare(left, right, key, *, left_name, right_name, ignore_columns, strict=True) Run a comparison, returns DiffResult.
DiffResult.summary Summary with counts per status plus found, not_found, total.
DiffResult.columns Tuple of ColumnInfo (kind, dtypes, mismatches, compare dtype).
DiffResult.frame(status=None, *, rows=None) Wide result, optionally filtered by status or row ids.
DiffResult.mismatches() Long format of all differing cells (values as text).
DiffResult.column_stats() Match rates per compared column.
DataFrameDiff(left, right, key, ...) The widget; same arguments as compare plus left_short, right_short, page_size (default 10).
DataFrameDiff.from_result(result, ...) Widget for an existing DiffResult.
DataFrameDiff.result The underlying DiffResult.
DataFrameDiff.selected_ids Synced trait with the checked row ids.
DataFrameDiff.selected_frame() Checked rows as a wide frame.
DataFrameDiff.view_frame() Rows matching the widget's current filters (not reactive).
Status StrEnum of the four statuses.

Performance

The comparison is one hash join plus vectorised expressions; the widget keeps the ordered row ids of the last few views in a small cache, so paging through a view is a slice. Timings from benchmarks/bench.py on a single CPU core (polars parallelises, so more cores are faster):

5 million rows per side time
compare (join, difference flags, statistics) 2.5 s
first page / next page < 1 ms
filter by status 19 ms
numeric range filter (left or right) 45 ms
sort by a column 0.4 s
full-text search across all columns 0.4 s

Memory: the joined frame holds both sides once, plus one boolean flag per compared column.

Limitations

  • The widget needs a running Python kernel; in a static HTML export it shows no rows.
  • CSV export sends all matching rows to the browser in one message – filter first for very large exports, or use view_frame().write_csv(...).
  • view_frame() reflects the filters last sent by the widget but is not a reactive value in marimo.

Development

git clone https://github.com/oberbichler/dference && cd dference
uv sync                                # creates .venv with all dev tools

uv run ruff format .                   # format
uv run ruff check .                    # lint
uv run ty check                        # type check
uv run pytest --cov                    # tests with coverage
uv run playwright install chromium     # once: browser for the widget tests
uv run pytest tests/e2e                # widget in a real browser (skipped without Chromium)

uv run marimo edit examples/demo.py    # interactive demo
uv run python benchmarks/bench.py 1_000_000

The frontend is a single dependency-free ES module (src/dference/static/widget.js) with its stylesheet next to it – no build step. anywidget reloads both on change while you develop.

Node.js is only needed for frontend tooling, never at runtime:

npm ci                                 # Biome + TypeScript (dev only)

npm run fix                            # format + auto-fix JS, CSS and JSON (Biome)
npm run check                          # lint (Biome) and type-check the JSDoc (tsc)

Types live in JSDoc comments and are checked with tsc --checkJs in strict mode (see tsconfig.json); the widget API is typed via @anywidget/types. Nothing is compiled – the file that ships is the file you edit.

Releasing

The version is derived from the git tag (v1.2.3) by hatch-vcs – there is no version to bump in pyproject.toml or uv.lock. A checkout between releases reports a dev version such as 0.1.1.dev3+g94570d5.

For every release:

  1. Rename ## [Unreleased] in CHANGELOG.md to ## [x.y.z] - <date> (add a new empty Unreleased section above it) and merge that to main.
  2. Run Actions → Release → Run workflow on main with the version x.y.z, or gh workflow run release.yml -f version=x.y.z.

The workflow refuses versions that already exist as tag, release or on PyPI, runs the full CI on the commit, builds with that version, publishes to PyPI via trusted publishing, and only then tags the commit vx.y.z and creates the GitHub release with the changelog section as notes and the wheel and sdist attached. If a step fails, Re-run failed jobs continues from there.

Once, before the first release: on PyPI add a pending trusted publisher for the project dference – owner oberbichler, repository dference, workflow release.yml, environment pypi – and create the environment pypi in the GitHub repository settings (optionally with required reviewers).

License

ISC © Thomas Oberbichler

Metadata

Release files for dference 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for dference 0.1.0
File Size Uploaded
dference-0.1.0.tar.gz 69.3 kB Details

Built distribution (wheel)

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

Total release size: 115.2 kB

Release files / dference-0.1.0.tar.gz

Download URL dference-0.1.0.tar.gz
Size 69.3 kB
Tags Source
SHA-256 checksum
How to use checksums
031738675de49464a42bc82a39f1d9dc117e814274d7e449d889b45ac5041a4f
BLAKE2b-256 checksum
How to use checksums
a24ff910ffa4c29dfdf320b4725a49b533302e64def83ad4e60aa7fc1c0de6f8
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 Oct 6, 2026.

Transparency log

Release files / dference-0.1.0-py3-none-any.whl

Download URL dference-0.1.0-py3-none-any.whl
Size 46.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
ab049247d0d226ffa21e8d7f46ad6e18c2985a4966d841da43153fea2a258da6
BLAKE2b-256 checksum
How to use checksums
db386ece3101f8781112ecac2a006d969ba74ae3b8094ad5a79ac5c4addd33b4
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 Oct 6, 2026.

Transparency log

Release history Release notifications | RSS feed

0.3.0

2 release files

0.2.0

2 release files

This release

0.1.0 This release

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