diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
deleted file mode 100644
index cac9700..0000000
--- a/.github/workflows/publish.yml
+++ /dev/null
@@ -1,51 +0,0 @@
-name: Publish Python Package
-
-on:
-  release:
-    types: [created]
-
-permissions:
-  contents: read
-
-jobs:
-  test:
-    runs-on: ubuntu-latest
-    strategy:
-      matrix:
-        python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
-    steps:
-    - uses: actions/checkout@v4
-    - name: Set up Python ${{ matrix.python-version }}
-      uses: actions/setup-python@v5
-      with:
-        python-version: ${{ matrix.python-version }}
-        cache: pip
-        cache-dependency-path: setup.py
-    - name: Install dependencies
-      run: |
-        pip install '.[test]'
-    - name: Run tests
-      run: |
-        pytest
-  deploy:
-    runs-on: ubuntu-latest
-    needs: [test]
-    environment: release
-    permissions:
-      id-token: write
-    steps:
-    - uses: actions/checkout@v4
-    - name: Set up Python
-      uses: actions/setup-python@v5
-      with:
-        python-version: "3.12"
-        cache: pip
-        cache-dependency-path: setup.py
-    - name: Install dependencies
-      run: |
-        pip install setuptools wheel build
-    - name: Build
-      run: |
-        python -m build
-    - name: Publish
-      uses: pypa/gh-action-pypi-publish@release/v1
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
deleted file mode 100644
index f7d84e1..0000000
--- a/.github/workflows/test.yml
+++ /dev/null
@@ -1,27 +0,0 @@
-name: Test
-
-on: [push, pull_request]
-
-permissions:
-  contents: read
-
-jobs:
-  test:
-    runs-on: ubuntu-latest
-    strategy:
-      matrix:
-        python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
-    steps:
-    - uses: actions/checkout@v4
-    - name: Set up Python ${{ matrix.python-version }}
-      uses: actions/setup-python@v5
-      with:
-        python-version: ${{ matrix.python-version }}
-        cache: pip
-        cache-dependency-path: setup.py
-    - name: Install dependencies
-      run: |
-        pip install '.[test]'
-    - name: Run tests
-      run: |
-        pytest
diff --git a/.gitignore b/.gitignore
index df13fc5..f107b77 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,11 +1,19 @@
-.venv
+# Virtual environments
+.venv/
+venv/
+
+# Bytecode and compiled files
 __pycache__/
 *.py[cod]
 *$py.class
-venv
-.eggs
-.pytest_cache
-*.egg-info
+
+# Packaging
+.eggs/
+*.egg-info/
+
+# Testing
+.pytest_cache/
+
+# OS and IDE
 .DS_Store
-.schema
-.vscode
+.vscode/
diff --git a/Dockerfile b/Dockerfile
deleted file mode 100644
index 14b3de0..0000000
--- a/Dockerfile
+++ /dev/null
@@ -1,5 +0,0 @@
-FROM python:3.12-alpine
-RUN pip install csv-diff
-WORKDIR /files
-ENTRYPOINT ["csv-diff"]
-CMD ["--help"]
diff --git a/LICENSE b/LICENSE
index 261eeb9..0bda937 100644
--- a/LICENSE
+++ b/LICENSE
@@ -186,7 +186,7 @@
       same "printed page" as the copyright notice for easier
       identification within third-party archives.
 
-   Copyright [yyyy] [name of copyright owner]
+   Copyright 2025 Thomas Coyle
 
    Licensed under the Apache License, Version 2.0 (the "License");
    you may not use this file except in compliance with the License.
diff --git a/README.md b/README.md
index 61c9acf..5dcd889 100644
--- a/README.md
+++ b/README.md
@@ -1,157 +1,388 @@
-# csv-diff
+# db-diff
 
-[![PyPI](https://img.shields.io/pypi/v/csv-diff.svg)](https://pypi.org/project/csv-diff/)
-[![Changelog](https://img.shields.io/github/v/release/simonw/csv-diff?include_prereleases&label=changelog)](https://github.com/simonw/csv-diff/releases)
-[![Tests](https://github.com/simonw/csv-diff/workflows/Test/badge.svg)](https://github.com/simonw/csv-diff/actions?query=workflow%3ATest)
-[![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](https://github.com/simonw/csv-diff/blob/main/LICENSE)
+[![Changelog](https://img.shields.io/github/v/release/datsom1/db-diff?include_prereleases&label=changelog)](https://github.com/datsom1/db-diff/releases)
+[![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](https://github.com/datsom1/db-diff/blob/main/LICENSE)
+[![Python Versions](https://img.shields.io/badge/python-3.6%2B-blue)](https://github.com/datsom1/db-diff)
 
-Tool for viewing the difference between two CSV, TSV or JSON files. See [Generating a commit log for San Francisco’s official list of trees](https://simonwillison.net/2019/Mar/13/tree-history/) (and the [sf-tree-history repo commit log](https://github.com/simonw/sf-tree-history/commits)) for background information on this project.
+**Key features:**
+
+- **Custom Key Column:** Use `--key` to specify which column uniquely identifies rows.
+- **Output Formats:** Choose between human-readable (`readable`), JSON (`json`), or save JSON to a file (`jsonfile`).
+- **Field Selection:** Use `--fields` to compare only specific columns, or `--ignorefields` to exclude columns.
+- **Streaming Mode:** For very large files, choose from multiple streaming modes (`--streaming=auto|normal|sortedkey|off`) to optimize performance.
+- **Encoding:** Specify file encoding with `--encoding`.
+- **Show Unchanged:** Use `--showunchanged` to display unchanged fields for changed rows.
+- **List Fields:** Use `--listfields` to print available columns and exit.
+- **Performance Metrics:** Automatically displays elapsed time for diff operations.
+
+
+> **db-diff** is the most advanced and user-friendly Python CLI tool and library for comparing CSV, TSV, and JSON database dumps. Designed specifically for database workflows, it delivers precise, human-readable or machine-readable diffs, supports custom key columns, handles massive files efficiently with streaming mode, and offers flexible field selection.
+
+---
+
+## Table of Contents
+
+- [Features](#features)
+- [Installation](#installation)
+- [Quick Start](#quick-start)
+- [Usage](#usage)
+  - [Command Line](#command-line)
+  - [Python Library](#python-library)
+- [Options](#options)
+- [Examples](#examples)
+- [Performance](#performance)
+
+---
+
+## Features
+
+- Compare CSV, TSV, and JSON files for differences
+- Human-readable and machine-readable (JSON) output
+- JSON output to file with filename and path support
+- Optional timestamp in output filenames
+- Detects added, removed, and changed rows and columns
+- Supports custom key columns for row identity
+- Field inclusion/exclusion for focused diffs
+- Streaming mode for very large files (memory efficient)
+- Can output to terminal or JSON file
+- Automatic delimiter and encoding detection
+- Python 3.6+ compatible
+
+---
 
 ## Installation
 
-    pip install csv-diff
+### From GitHub (Latest Development Version)
+
+```sh
+pip install git+https://github.com/datsom1/db-diff.git
+```
+
+To upgrade to the latest version:
+
+```sh
+pip install --upgrade --force-reinstall git+https://github.com/datsom1/db-diff.git
+```
+
+### Requirements
+
+- Python 3.6 or higher
+- Dependencies are automatically installed with pip
+
+---
+
+## Quick Start
+
+Suppose you have two CSV files:
+
+**one.csv**
+```
+Id,name,age
+1,Cleo,4
+2,Pancakes,2
+```
+
+**two.csv**
+```
+Id,name,age
+1,Cleo,5
+3,Bailey,1
+```
+
+Compare them using:
+
+```sh
+db-diff one.csv two.csv --key=Id
+```
+
+Sample output:
+
+```
+1 rows changed, 1 rows added, 1 rows removed
+
+1 rows changed
+
+  Id: 1
+    age: "4" => "5"
+
+1 rows added
+
+  Id: 3
+  name: Bailey
+  age: 1
+
+1 rows removed
+
+  Id: 2
+  name: Pancakes
+  age: 2
+```
+
+---
 
 ## Usage
 
-Consider two CSV files:
+### Command Line
+
+`db-diff` is a flexible CLI tool for comparing two data files (CSV, TSV, or JSON). It detects added, removed, and changed rows and columns, and can output results in a human-readable or machine-readable format.
+
+**Basic usage:**
+
+```sh
+db-diff [OPTIONS] PREVIOUS CURRENT
+```
+
+- `PREVIOUS` and `CURRENT` are the file paths to the two files you want to compare.
+- The tool auto-detects file format by extension, or you can specify with `--format`.
+
+**Key features:**
+
+- **Custom Key Column:** Use `--key` to specify which column uniquely identifies rows.
+- **Output Formats:** Choose between human-readable (`readable`), JSON (`json`), or save JSON to a file (`jsonfile`).
+- **Field Selection:** Use `--fields` to compare only specific columns, or `--ignorefields` to exclude columns.
+- **Streaming Mode Options:** Choose between different streaming modes for CSV/TSV files:
+  - `--streaming=auto` (default): Automatically select the best mode based on file size and key ordering
+  - `--streaming=normal`: Memory-efficient but slower processing
+  - `--streaming=sortedkey`: Very fast streaming mode for files with sorted key columns
+  - `--streaming=off`: Disable streaming regardless of file size
+- **Encoding:** Specify file encoding with `--encoding`.
+- **Show Unchanged:** Use `--showunchanged` to display unchanged fields for changed rows.
+- **List Fields:** Use `--listfields` to print available columns and exit.
+- **Timing:** Use `--time` to display how long the diff operation took.
+
+**See all options:**
+
+```sh
+db-diff --help
+```
+
+---
 
-`one.csv`
+### Python Library
 
-    id,name,age
-    1,Cleo,4
-    2,Pancakes,2
+You can use `db-diff` as a Python library for advanced or automated workflows. The library provides functions to load data, compare datasets, and render results.
 
-`two.csv`
+#### Loading Data
 
-    id,name,age
-    1,Cleo,5
-    3,Bailey,1
+```python
+from db_diff import load_csv, load_json
 
-`csv-diff` can show a human-readable summary of differences between the files:
+# Load CSV file, using a specific column as the key
+with open("one.csv", encoding="utf-8") as f:
+    prev = load_csv(f, key="Id")
 
-    $ csv-diff one.csv two.csv --key=id
-    1 row changed, 1 row added, 1 row removed
+# Load JSON file, using a specific key
+with open("two.json", encoding="utf-8") as f:
+    curr = load_json(f, key="Id")
+```
 
-    1 row changed
+#### Comparing Data
 
-      Row 1
-        age: "4" => "5"
+```python
+from db_diff import compare
 
-    1 row added
+# Compare two datasets (dictionaries keyed by your chosen column)
+diff = compare(prev, curr, show_unchanged=False)
+```
 
-      id: 3
-      name: Bailey
-      age: 1
+- `show_unchanged`: If True, includes unchanged fields for changed rows.
+- `fields`: Pass a set of field names to only compare those fields.
+- `ignorefields`: Pass a set of field names to ignore during comparison.
+
+#### Streaming Comparison (for large CSV/TSV files)
+
+```python
+# For normal streaming mode (memory efficient)
+from db_diff import streaming_compare_csv
+
+diff = streaming_compare_csv(
+    "one.csv",
+    "two.csv",
+    key="Id",
+    compare_columns={"Id", "name", "age"},
+    encoding="utf-8",
+    dialect="excel"
+)
+
+# For sorted key streaming mode (faster, but requires sorted keys)
+from db_diff import streaming_compare_csv_sorted
+
+diff = streaming_compare_csv_sorted(
+    "one.csv",
+    "two.csv",
+    key="Id",
+    compare_columns={"Id", "name", "age"},
+    encoding="utf-8",
+    dialect="excel"
+)
+
+# Check if a file's key column is sorted
+from db_diff import is_key_column_sorted
+
+is_sorted = is_key_column_sorted(
+    "one.csv",
+    key="Id",
+    encoding="utf-8",
+    dialect="excel"
+)
+```
 
-    1 row removed
+#### Diff Result Structure
+
+The result of `compare` or `streaming_compare_csv` is a dictionary:
+
+```python
+{
+    "added": [ ... ],            # List of added rows (dicts)
+    "removed": [ ... ],          # List of removed rows (dicts)
+    "changed": [                 # List of changed rows
+        {
+            "key": "row_id",
+            "changes": {
+                "field1": ["old", "new"],
+                ...
+            },
+            "unchanged": { ... } # (optional) if show_unchanged=True
+        },
+        ...
+    ],
+    "columns_added": [ ... ],    # List of columns added
+    "columns_removed": [ ... ]   # List of columns removed
+}
+```
 
-      id: 2
-      name: Pancakes
-      age: 2
+#### Rendering Human-Readable Output
 
-The `--key=id` option means that the `id` column should be treated as the unique key, to identify which records have changed.
+```python
+from db_diff import human_text
 
-The tool will automatically detect if your files are comma- or tab-separated. You can over-ride this automatic detection and force the tool to use a specific format using `--format=tsv` or `--format=csv`.
+print(human_text(diff, key="Id", current=curr))
+```
 
-You can also feed it JSON files, provided they are a JSON array of objects where each object has the same keys. Use `--format=json` if your input files are JSON.
+#### Example: Full Workflow
 
-Use `--show-unchanged` to include full details of the unchanged values for rows with at least one change in the diff output:
+```python
+from db_diff import load_csv, compare, human_text
 
-    % csv-diff one.csv two.csv --key=id --show-unchanged
-    1 row changed
+with open("one.csv") as f1, open("two.csv") as f2:
+    prev = load_csv(f1, key="Id")
+    curr = load_csv(f2, key="Id")
+    diff = compare(prev, curr, show_unchanged=True)
+    print(human_text(diff, key="Id", current=curr))
+```
 
-      id: 1
-        age: "4" => "5"
+---
 
-        Unchanged:
-          name: "Cleo"
+## Options
 
-### JSON output
+See all available options with:
 
-You can use the `--json` option to get a machine-readable difference:
+```sh
+db-diff --help
+```
 
-    $ csv-diff one.csv two.csv --key=id --json
-    {
-        "added": [
-            {
-                "id": "3",
-                "name": "Bailey",
-                "age": "1"
-            }
-        ],
-        "removed": [
-            {
-                "id": "2",
-                "name": "Pancakes",
-                "age": "2"
-            }
-        ],
-        "changed": [
-            {
-                "key": "1",
-                "changes": {
-                    "age": [
-                        "4",
-                        "5"
-                    ]
-                }
-            }
-        ],
-        "columns_added": [],
-        "columns_removed": []
-    }
+A summary of key options:
+
+| Option                | Description                                                                                  |
+|-----------------------|----------------------------------------------------------------------------------------------|
+| `--key TEXT`          | Column to use as a unique ID for each row (default: first column header)                     |
+| `--output TEXT`       | Output format: `readable`, `json`, or `jsonfile` (default: readable)                         |
+| `--outfilename FILE`  | File to write JSON output to (used with `--output=jsonfile`)                                 |
+| `--outfilepath DIR`   | Directory to save the output file (used with `--output=jsonfile`)                            |
+| `--timestamp`         | Add timestamp to filename (YYYY-MM-DD_HH-MM-SS_diffs_<filename>.json)                       |
+| `--fields TEXT`       | Comma-separated list of fields to compare (all others ignored)                               |
+| `--ignorefields TEXT` | Comma-separated list of fields to ignore during comparison                                   |
+| `--showunchanged`     | Show all fields for changed records, not just changed fields                                 |
+| `--format TEXT`       | Explicitly specify input format: `csv`, `tsv`, or `json` (default: auto-detect)              |
+| `--encoding TEXT`     | Input file encoding (default: utf-8)                                                         |
+| `--streaming=MODE`    | Streaming mode for CSV/TSV files. Options: `auto` (default), `normal`, `sortedkey`, `off`    |
+| `--listfields`        | List available fields/columns in the input files and exit                                    |
+| `--verbose`           | Enable verbose output with detailed logging                                                  |
+| `--samplesize NUM`    | Number of rows to sample when checking if key columns are sorted (default: 1000)             |
+| `--version`           | Show the version and exit                                                                    |
+| `-h, --help`          | Show help message and exit                                                                   |
+
+---
+
+## Examples
+
+**Show unchanged fields for changed rows:**
+
+```sh
+db-diff one.csv two.csv --key=Id --showunchanged
+```
 
-### Adding templated extras
+**Output as JSON:**
 
-You can specify additional keys to be displayed in the human-readable format using the `--extra` option:
+```sh
+db-diff one.csv two.csv --key=Id --output=json
+```
 
-    --extra name "Python format string with {id} for variables"
+**Save JSON output to a file:**
 
-For example, to output a link to `https://news.ycombinator.com/latest?id={id}` for each item with an ID, you could use this:
+```sh
+db-diff one.csv two.csv --key=Id --output=jsonfile --outfilename=diffs.json
+```
+
+**Save JSON output with timestamp in filename:**
 
-```bash
-csv-diff one.csv two.csv --key=id \
-  --extra latest "https://news.ycombinator.com/latest?id={id}"
+```sh
+db-diff one.csv two.csv --key=Id --output=jsonfile --timestamp
 ```
-These extras display something like this:
+
+**Compare only specific fields:**
+
+```sh
+db-diff one.csv two.csv --key=Id --fields=Id,name
 ```
-1 row changed
 
-  id: 41459472
-    points: "24" => "25"
-    numComments: "5" => "6"
-  extras:
-    latest: https://news.ycombinator.com/latest?id=41459472
+**Ignore specific fields:**
+
+```sh
+db-diff one.csv two.csv --key=Id --ignorefields=LastModifiedDate
 ```
 
-## As a Python library
+**Streaming mode options for large files:**
+
+```sh
+# Auto mode (default) - automatically selects the best streaming mode
+db-diff large1.csv large2.csv --key=Id --streaming=auto
 
-You can also import the Python library into your own code like so:
+# Normal streaming mode - memory efficient but slower
+db-diff large1.csv large2.csv --key=Id --streaming=normal
 
-    from csv_diff import load_csv, compare
-    diff = compare(
-        load_csv(open("one.csv"), key="id"),
-        load_csv(open("two.csv"), key="id")
-    )
+# Sorted key streaming mode - very fast for files with sorted keys
+db-diff large1.csv large2.csv --key=Id --streaming=sortedkey
 
-`diff` will now contain the same data structure as the output in the `--json` example above.
+# Disable streaming regardless of file size
+db-diff large1.csv large2.csv --key=Id --streaming=off
 
-If the columns in the CSV have changed, those added or removed columns will be ignored when calculating changes made to specific rows.
+# Adjust the sample size for key column sorting detection
+db-diff large1.csv large2.csv --key=Id --samplesize=5000
+```
 
-## As a Docker container
+The streaming modes work with CSV/TSV files only. The `auto` mode will automatically detect if the key columns are sorted and select the most efficient streaming mode for your data.
 
-### Build the image
+**Verbose output with detailed logging:**
 
-    $ docker build -t csvdiff .
+```sh
+db-diff one.csv two.csv --key=Id --verbose
+```
 
-### Run the container
+---
 
-    $ docker run --rm -v $(pwd):/files csvdiff
+## Performance
 
-Suppose current directory contains two csv files : one.csv two.csv
+- Choose the appropriate streaming mode for your data:
+  - `auto` (default): Automatically selects the best mode based on file properties
+  - `sortedkey`: Fastest streaming mode, but requires key columns to be sorted in both files
+  - `normal`: Memory-efficient but slower, good for very large unsorted files
+  - `off`: Use when you want to process the entire files in memory regardless of size
+- The tool automatically detects if key columns are sorted and selects the most efficient mode
+- For very large files, the sorted key streaming mode can be 2-5x faster than normal streaming
+- Streaming modes read files in chunks, so they use significantly less memory than loading entire files
 
-    $ docker run --rm -v $(pwd):/files csvdiff one.csv two.csv
-    
-## Alternatives
+---
 
-- [csvdiff](https://github.com/aswinkarthik/csvdiff) is a "fast diff tool for comparing CSV files" - you may get better results from this than from `csv-diff` against larger files.
+**Author:** Thomas Coyle  
+**Repository:** https://github.com/datsom1/db-diff  
diff --git a/csv_diff/__init__.py b/csv_diff/__init__.py
deleted file mode 100644
index 59a2eaf..0000000
--- a/csv_diff/__init__.py
+++ /dev/null
@@ -1,212 +0,0 @@
-import csv
-from dictdiffer import diff
-import json
-import hashlib
-
-
-def load_csv(fp, key=None, dialect=None):
-    if dialect is None and fp.seekable():
-        # Peek at first 1MB to sniff the delimiter and other dialect details
-        peek = fp.read(1024**2)
-        fp.seek(0)
-        try:
-            dialect = csv.Sniffer().sniff(peek, delimiters=",\t;")
-        except csv.Error:
-            # Oh well, we tried. Fallback to the default.
-            pass
-    fp = csv.reader(fp, dialect=(dialect or "excel"))
-    headings = next(fp)
-    rows = [dict(zip(headings, line)) for line in fp]
-    if key:
-        keyfn = lambda r: r[key]
-    else:
-        keyfn = lambda r: hashlib.sha1(
-            json.dumps(r, sort_keys=True).encode("utf8")
-        ).hexdigest()
-    return {keyfn(r): r for r in rows}
-
-
-def load_json(fp, key=None):
-    raw_list = json.load(fp)
-    assert isinstance(raw_list, list)
-    common_keys = set()
-    for item in raw_list:
-        common_keys.update(item.keys())
-    if key:
-        keyfn = lambda r: r[key]
-    else:
-        keyfn = lambda r: hashlib.sha1(
-            json.dumps(r, sort_keys=True).encode("utf8")
-        ).hexdigest()
-    return {keyfn(r): _simplify_json_row(r, common_keys) for r in raw_list}
-
-
-def _simplify_json_row(r, common_keys):
-    # Convert list/dict values into JSON serialized strings
-    for key, value in r.items():
-        if isinstance(value, (dict, tuple, list)):
-            r[key] = json.dumps(value)
-    for key in common_keys:
-        if key not in r:
-            r[key] = None
-    return r
-
-
-def compare(previous, current, show_unchanged=False):
-    result = {
-        "added": [],
-        "removed": [],
-        "changed": [],
-        "columns_added": [],
-        "columns_removed": [],
-    }
-    # Have the columns changed?
-    previous_columns = set(next(iter(previous.values())).keys())
-    current_columns = set(next(iter(current.values())).keys())
-    ignore_columns = None
-    if previous_columns != current_columns:
-        result["columns_added"] = [
-            c for c in current_columns if c not in previous_columns
-        ]
-        result["columns_removed"] = [
-            c for c in previous_columns if c not in current_columns
-        ]
-        ignore_columns = current_columns.symmetric_difference(previous_columns)
-    # Have any rows been removed or added?
-    removed = [id for id in previous if id not in current]
-    added = [id for id in current if id not in previous]
-    # How about changed?
-    removed_or_added = set(removed) | set(added)
-    potential_changes = [id for id in current if id not in removed_or_added]
-    changed = [id for id in potential_changes if current[id] != previous[id]]
-    if added:
-        result["added"] = [current[id] for id in added]
-    if removed:
-        result["removed"] = [previous[id] for id in removed]
-    if changed:
-        for id in changed:
-            diffs = list(diff(previous[id], current[id], ignore=ignore_columns))
-            if diffs:
-                changes = {
-                    "key": id,
-                    "changes": {
-                        # field can be a list if id contained '.' - #7
-                        field[0] if isinstance(field, list) else field: [
-                            prev_value,
-                            current_value,
-                        ]
-                        for _, field, (prev_value, current_value) in diffs
-                    },
-                }
-                if show_unchanged:
-                    changes["unchanged"] = {
-                        field: value
-                        for field, value in previous[id].items()
-                        if field not in changes["changes"] and field != "id"
-                    }
-                result["changed"].append(changes)
-    return result
-
-
-def human_text(result, key=None, singular=None, plural=None, current=None, extras=None):
-    singular = singular or "row"
-    plural = plural or "rows"
-    title = []
-    summary = []
-    show_headers = sum(1 for key in result if result[key]) > 1
-    if result["columns_added"]:
-        fragment = "{} {} added".format(
-            len(result["columns_added"]),
-            "column" if len(result["columns_added"]) == 1 else "columns",
-        )
-        title.append(fragment)
-        summary.extend(
-            [fragment, ""]
-            + ["  {}".format(c) for c in sorted(result["columns_added"])]
-            + [""]
-        )
-    if result["columns_removed"]:
-        fragment = "{} {} removed".format(
-            len(result["columns_removed"]),
-            "column" if len(result["columns_removed"]) == 1 else "columns",
-        )
-        title.append(fragment)
-        summary.extend(
-            [fragment, ""]
-            + ["  {}".format(c) for c in sorted(result["columns_removed"])]
-            + [""]
-        )
-    if result["changed"]:
-        fragment = "{} {} changed".format(
-            len(result["changed"]), singular if len(result["changed"]) == 1 else plural
-        )
-        title.append(fragment)
-        if show_headers:
-            summary.append(fragment + "\n")
-        change_blocks = []
-        for details in result["changed"]:
-            block = []
-            block.append("  {}: {}".format(key, details["key"]))
-            for field, (prev_value, current_value) in details["changes"].items():
-                block.append(
-                    '    {}: "{}" => "{}"'.format(field, prev_value, current_value)
-                )
-            if extras:
-                current_item = current[details["key"]]
-                block.append(human_extras(current_item, extras))
-            block.append("")
-            change_blocks.append("\n".join(block))
-            if details.get("unchanged"):
-                block = []
-                block.append("    Unchanged:")
-                for field, value in details["unchanged"].items():
-                    block.append('      {}: "{}"'.format(field, value))
-                block.append("")
-                change_blocks.append("\n".join(block))
-        summary.append("\n".join(change_blocks))
-    if result["added"]:
-        fragment = "{} {} added".format(
-            len(result["added"]), singular if len(result["added"]) == 1 else plural
-        )
-        title.append(fragment)
-        if show_headers:
-            summary.append(fragment + "\n")
-        rows = []
-        for row in result["added"]:
-            to_append = human_row(row, prefix="  ")
-            if extras:
-                to_append += "\n" + human_extras(row, extras)
-            rows.append(to_append)
-        summary.append("\n\n".join(rows))
-        summary.append("")
-    if result["removed"]:
-        fragment = "{} {} removed".format(
-            len(result["removed"]), singular if len(result["removed"]) == 1 else plural
-        )
-        title.append(fragment)
-        if show_headers:
-            summary.append(fragment + "\n")
-        rows = []
-        for row in result["removed"]:
-            to_append = human_row(row, prefix="  ")
-            if extras:
-                to_append += "\n" + human_extras(row, extras)
-            rows.append(to_append)
-        summary.append("\n\n".join(rows))
-        summary.append("")
-    return (", ".join(title) + "\n\n" + ("\n".join(summary))).strip()
-
-
-def human_row(row, prefix=""):
-    bits = []
-    for key, value in row.items():
-        bits.append("{}{}: {}".format(prefix, key, value))
-    return "\n".join(bits)
-
-
-def human_extras(row, extras):
-    bits = []
-    bits.append("  extras:")
-    for key, fmt in extras:
-        bits.append("    {}: {}".format(key, fmt.format(**row)))
-    return "\n".join(bits)
diff --git a/csv_diff/cli.py b/csv_diff/cli.py
deleted file mode 100644
index 261ef6d..0000000
--- a/csv_diff/cli.py
+++ /dev/null
@@ -1,82 +0,0 @@
-import click
-import json as std_json
-from . import load_csv, load_json, compare, human_text
-
-
-@click.command()
-@click.version_option()
-@click.argument(
-    "previous",
-    type=click.Path(exists=True, file_okay=True, dir_okay=False, allow_dash=False),
-)
-@click.argument(
-    "current",
-    type=click.Path(exists=True, file_okay=True, dir_okay=False, allow_dash=False),
-)
-@click.option(
-    "--key", type=str, default=None, help="Column to use as a unique ID for each row"
-)
-@click.option(
-    "--format",
-    type=click.Choice(["csv", "tsv", "json"]),
-    default=None,
-    help="Explicitly specify input format (csv, tsv, json) instead of auto-detecting",
-)
-@click.option(
-    "--json", type=bool, default=False, help="Output changes as JSON", is_flag=True
-)
-@click.option(
-    "--singular",
-    type=str,
-    default=None,
-    help="Singular word to use, e.g. 'tree' for '1 tree'",
-)
-@click.option(
-    "--plural",
-    type=str,
-    default=None,
-    help="Plural word to use, e.g. 'trees' for '2 trees'",
-)
-@click.option(
-    "--show-unchanged",
-    is_flag=True,
-    help="Show unchanged fields for rows with at least one change",
-)
-@click.option(
-    "extras",
-    "--extra",
-    type=(str, str),
-    multiple=True,
-    help="key: format string - define extra fields to display",
-)
-def cli(previous, current, key, format, json, singular, plural, show_unchanged, extras):
-    "Diff two CSV or JSON files"
-    dialect = {
-        "csv": "excel",
-        "tsv": "excel-tab",
-    }
-
-    if extras and json:
-        raise click.UsageError(
-            "Extra fields are not supported in JSON output mode",
-            ctx=click.get_current_context(),
-        )
-
-    def load(filename):
-        if format == "json":
-            return load_json(open(filename), key=key)
-        else:
-            return load_csv(
-                open(filename, newline=""), key=key, dialect=dialect.get(format)
-            )
-
-    previous_data = load(previous)
-    current_data = load(current)
-
-    diff = compare(previous_data, current_data, show_unchanged)
-    if json:
-        print(std_json.dumps(diff, indent=4))
-    else:
-        print(
-            human_text(diff, key, singular, plural, current=current_data, extras=extras)
-        )
diff --git a/db_diff/__init__.py b/db_diff/__init__.py
new file mode 100644
index 0000000..1919fa0
--- /dev/null
+++ b/db_diff/__init__.py
@@ -0,0 +1,690 @@
+"""
+db-diff - A tool for comparing database dumps in CSV or JSON format.
+
+This module provides functions for loading and comparing data from CSV and JSON files,
+generating diffs, and displaying results in human-readable format.
+"""
+
+import csv
+import json
+import xxhash
+from dictdiffer import diff
+import logging
+
+# Configure logging
+logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s')
+logger = logging.getLogger(__name__)
+
+def _generate_hash_key(row):
+    """
+    Generate a hash key for a row.
+    
+    Args:
+        row: Row data dictionary
+        
+    Returns:
+        Hash key as a string
+    """
+    return xxhash.xxh64(json.dumps(row, sort_keys=True).encode("utf8")).hexdigest()
+
+def load_csv(fp, key=None, dialect=None):
+    """
+    Load a CSV file into a dictionary keyed by the given column or hash.
+    
+    Args:
+        fp: File-like object containing CSV data
+        key: Column name to use as the dictionary key (optional)
+        dialect: CSV dialect to use (optional)
+        
+    Returns:
+        Dictionary of rows keyed by the specified column or hash
+    """
+    if dialect is None and fp.seekable():
+        # Peek at first 1MB to sniff the delimiter and other dialect details
+        peek = fp.read(1024**2)
+        fp.seek(0)
+        try:
+            dialect = csv.Sniffer().sniff(peek, delimiters=",\t;")
+        except csv.Error:
+            logger.debug("Could not sniff CSV dialect, falling back to default")
+            # Oh well, we tried. Fallback to the default.
+            pass
+    reader = csv.reader(fp, dialect=(dialect or "excel"))
+    headings = next(reader)
+    
+    # Process each row to ensure consistent handling of values
+    rows = []
+    for line in reader:
+        # Create a dictionary from the header and values
+        row_dict = dict(zip(headings, line))
+        # Process string values to ensure consistency with streaming mode
+        processed_row = {k: v.strip() if isinstance(v, str) else v for k, v in row_dict.items()}
+        rows.append(processed_row)
+    
+    if key:
+        keyfn = lambda r: r[key]
+    else:
+        keyfn = _generate_hash_key
+    return {keyfn(r): r for r in rows}
+
+def load_json(fp, key=None):
+    """
+    Load a JSON array of objects into a dictionary keyed by the given column or hash.
+    
+    Args:
+        fp: File-like object containing JSON data
+        key: Key to use for dictionary indexing (optional)
+        
+    Returns:
+        Dictionary of rows keyed by the specified column or hash
+        
+    Raises:
+        ValueError: If the JSON file doesn't contain a list of objects
+    """
+    raw_list = json.load(fp)
+    if not isinstance(raw_list, list):
+        raise ValueError("JSON file must contain a list of objects.")
+    if not raw_list:
+        logger.warning("JSON file contains an empty list")
+        return {}
+        
+    common_keys = set()
+    for item in raw_list:
+        common_keys.update(item.keys())
+        
+    if key and key not in common_keys:
+        logger.warning(f"Key '{key}' not found in all JSON objects, some items may be skipped")
+        
+    if key:
+        keyfn = lambda r: r.get(key, str(id(r)))
+    else:
+        keyfn = _generate_hash_key
+        
+    return {keyfn(r): _simplify_json_row(r, common_keys) for r in raw_list}
+
+def _simplify_json_row(r, common_keys):
+    """
+    Ensure all rows have the same keys and serialize nested structures.
+    
+    Args:
+        r: Row data (dictionary)
+        common_keys: Set of all keys found across all rows
+        
+    Returns:
+        Processed row with standardized format
+    """
+    result = r.copy()  # Create a copy to avoid modifying the original
+    
+    # Serialize nested structures
+    for key, value in result.items():
+        if isinstance(value, (dict, tuple, list)):
+            result[key] = json.dumps(value)
+            
+    # Ensure all rows have the same keys
+    for key in common_keys:
+        if key not in result:
+            result[key] = None
+            
+    return result
+
+def _determine_columns_to_compare(prev_columns, curr_columns, fields=None, ignorefields=None):
+    """
+    Determine which columns to compare based on fields and ignorefields.
+    
+    Args:
+        prev_columns: Set of column names from previous data
+        curr_columns: Set of column names from current data
+        fields: List of specific fields to compare (optional)
+        ignorefields: List of fields to ignore in comparison (optional)
+        
+    Returns:
+        Tuple of (compare_columns, ignore_columns)
+    """
+    if fields:
+        compare_columns = set(fields)
+    elif ignorefields:
+        compare_columns = prev_columns | curr_columns
+        compare_columns -= set(ignorefields)
+    else:
+        compare_columns = prev_columns | curr_columns
+        
+    ignore_columns = (prev_columns | curr_columns) - compare_columns
+    
+    return compare_columns, ignore_columns
+
+def compare(previous, current, show_unchanged=False, fields=None, ignorefields=None):
+    """
+    Compare two dictionaries of rows and return a diff summary.
+    
+    Args:
+        previous: Dictionary of previous data rows
+        current: Dictionary of current data rows
+        show_unchanged: Whether to include unchanged fields in the output
+        fields: List of specific fields to compare (optional)
+        ignorefields: List of fields to ignore in comparison (optional)
+        
+    Returns:
+        Dictionary containing diff information
+    """
+    result = {
+        "added": [],
+        "removed": [],
+        "changed": [],
+        "columns_added": [],
+        "columns_removed": [],
+    }
+    
+    if not previous or not current:
+        logger.warning("One of the datasets is empty, comparison may be incomplete")
+        if not previous:
+            result["added"] = list(current.values())
+        if not current:
+            result["removed"] = list(previous.values())
+        return result
+        
+    # Have the columns changed?
+    previous_columns = set(next(iter(previous.values())).keys())
+    current_columns = set(next(iter(current.values())).keys())
+
+    # Apply fields/ignorefields filtering
+    compare_columns, ignore_columns = _determine_columns_to_compare(previous_columns, current_columns, fields, ignorefields)
+
+    # Adjust columns_added/removed based on compare_columns
+    result["columns_added"] = [c for c in current_columns if c not in previous_columns and c in compare_columns]
+    result["columns_removed"] = [c for c in previous_columns if c not in current_columns and c in compare_columns]
+    #ignore_columns = (previous_columns | current_columns) - compare_columns
+
+    # Have any rows been removed or added?
+    removed = [id for id in previous if id not in current]
+    added = [id for id in current if id not in previous]
+    removed_or_added = set(removed) | set(added)
+    potential_changes = [id for id in current if id not in removed_or_added]
+    
+    # Find changed rows by comparing values
+    changed = [
+        id for id in potential_changes
+        if any(
+            (k in compare_columns) and (current[id].get(k) != previous[id].get(k))
+            for k in compare_columns
+        )
+    ]
+    
+    # Build result dictionary
+    if added:
+        result["added"] = [current[id] for id in added]
+    if removed:
+        result["removed"] = [previous[id] for id in removed]
+    if changed:
+        for id in changed:
+            diffs = list(diff(
+                previous[id], current[id],
+                ignore=ignore_columns if ignore_columns else None
+            ))
+            if diffs:
+                changes = {
+                    "key": id,
+                    "changes": {
+                        field[0] if isinstance(field, list) else field: [
+                            prev_value,
+                            current_value,
+                        ]
+                        for _, field, (prev_value, current_value) in diffs
+                        if (field[0] if isinstance(field, list) else field) in compare_columns
+                    },
+                }
+                if show_unchanged:
+                    changes["unchanged"] = {
+                        field: value
+                        for field, value in previous[id].items()
+                        if field not in changes["changes"] and field != "id" and field in compare_columns
+                    }
+                result["changed"].append(changes)
+                
+    return result
+
+def streaming_compare_csv(prev_path, curr_path, key, compare_columns=None, ignorefields=None, encoding='utf-8', dialect='excel'):
+    """
+    Efficiently compare two CSV files by streaming, returning a diff dict.
+    This method uses less memory than loading the entire files into memory.
+    
+    Args:
+        prev_path: Path to the previous CSV file
+        curr_path: Path to the current CSV file
+        key: Column name to use as key for comparison
+        compare_columns: List of columns to compare (optional)
+        ignorefields: List of columns to ignore (optional)
+        encoding: File encoding (default: utf-8)
+        dialect: CSV dialect (default: excel)
+        
+    Returns:
+        Dictionary containing diff information
+    """
+    result = {
+        "added": [],
+        "removed": [],
+        "changed": [],
+        "columns_added": [],
+        "columns_removed": [],
+    }
+    
+    try:
+        # First pass: Read all keys from both files to determine added and removed rows
+        prev_keys = set()
+        curr_keys = set()
+        prev_columns = set()
+        curr_columns = set()
+        
+        # Ensure consistent handling of CSV values between streaming and non-streaming modes
+        def process_csv_row(row_dict):
+            # Apply the same transformations that would happen in the non-streaming mode
+            # This ensures consistent handling of values between modes
+            return {k: v.strip() if isinstance(v, str) else v for k, v in row_dict.items()}
+        
+        # Load all keys from previous file
+        logger.debug("Streaming: Reading keys from previous file")
+        with open(prev_path, newline='', encoding=encoding) as f:
+            reader = csv.DictReader(f, dialect=dialect)
+            if not reader.fieldnames:
+                logger.error("Previous CSV file appears to be empty or malformed")
+                return result
+            if key not in reader.fieldnames:
+                raise ValueError(f"Key column '{key}' not found in previous file")
+            
+            prev_columns = set(reader.fieldnames)
+            for row in reader:
+                processed_row = process_csv_row(row)
+                prev_keys.add(processed_row[key])
+        
+        # Load all keys from current file
+        logger.debug("Streaming: Reading keys from current file")
+        with open(curr_path, newline='', encoding=encoding) as f:
+            reader = csv.DictReader(f, dialect=dialect)
+            if not reader.fieldnames:
+                logger.error("Current CSV file appears to be empty or malformed")
+                return result
+            if key not in reader.fieldnames:
+                raise ValueError(f"Key column '{key}' not found in current file")
+            
+            curr_columns = set(reader.fieldnames)
+            for row in reader:
+                processed_row = process_csv_row(row)
+                curr_keys.add(processed_row[key])
+        
+        # Second pass: Process each file to extract data for added, removed and changed rows
+        logger.debug("Streaming: Determining added and removed rows")
+        removed_keys = prev_keys - curr_keys
+        added_keys = curr_keys - prev_keys
+        common_keys = prev_keys & curr_keys
+        
+        # Load previous file data for removed and changed rows
+        prev_data = {}
+        logger.debug("Streaming: Loading data for removed and changed rows from previous file")
+        with open(prev_path, newline='', encoding=encoding) as f:
+            reader = csv.DictReader(f, dialect=dialect)
+            
+            for row in reader:
+                processed_row = process_csv_row(row)
+                key_val = processed_row[key]
+                if key_val in removed_keys or key_val in common_keys:
+                    prev_data[key_val] = processed_row
+        
+        # Load current file data for added and changed rows
+        curr_data = {}
+        logger.debug("Streaming: Loading data for added and changed rows from current file")
+        with open(curr_path, newline='', encoding=encoding) as f:
+            reader = csv.DictReader(f, dialect=dialect)
+            
+            for row in reader:
+                processed_row = process_csv_row(row)
+                key_val = processed_row[key]
+                if key_val in added_keys or key_val in common_keys:
+                    curr_data[key_val] = processed_row
+        
+        # Determine columns to compare
+        compare_columns_set, ignore_columns = _determine_columns_to_compare(
+            prev_columns, curr_columns, compare_columns, ignorefields
+        )
+        
+        # Identify column changes
+        result["columns_added"] = [c for c in curr_columns if c not in prev_columns and c in compare_columns_set]
+        result["columns_removed"] = [c for c in prev_columns if c not in curr_columns and c in compare_columns_set]
+        
+        # Add removed rows to result
+        logger.debug(f"Streaming: Processing {len(removed_keys)} removed rows")
+        for key_val in removed_keys:
+            result["removed"].append(prev_data[key_val])
+        
+        # Add added rows to result
+        logger.debug(f"Streaming: Processing {len(added_keys)} added rows")
+        for key_val in added_keys:
+            result["added"].append(curr_data[key_val])
+        
+        # Process changed rows
+        logger.debug(f"Streaming: Processing {len(common_keys)} common rows for changes")
+        for key_val in common_keys:
+            prev_row = prev_data[key_val]
+            curr_row = curr_data[key_val]
+            
+            # Check for changes in compared columns
+            changed_fields = {
+                col: [prev_row.get(col), curr_row.get(col)]
+                for col in compare_columns_set
+                if col in prev_columns and col in curr_columns and prev_row.get(col) != curr_row.get(col)
+            }
+            
+            if changed_fields:
+                result["changed"].append({
+                    "key": key_val,
+                    "changes": changed_fields
+                })
+                
+    except (csv.Error, UnicodeDecodeError) as e:
+        logger.error(f"CSV processing error: {str(e)}")
+        raise
+        
+    return result
+
+def streaming_compare_csv_sorted(prev_path, curr_path, key, compare_columns=None, ignorefields=None, encoding='utf-8', dialect='excel'):
+    """
+    Efficiently compare two CSV files by streaming with sorted keys, returning a diff dict.
+    This method uses less memory than loading the entire files into memory and is faster
+    than the standard streaming mode because it assumes keys are sorted in both files.
+    
+    Args:
+        prev_path: Path to the previous CSV file
+        curr_path: Path to the current CSV file
+        key: Column name to use as key for comparison
+        compare_columns: List of columns to compare (optional)
+        ignorefields: List of columns to ignore (optional)
+        encoding: File encoding (default: utf-8)
+        dialect: CSV dialect (default: excel)
+        
+    Returns:
+        Dictionary containing diff information
+    """
+    result = {
+        "added": [],
+        "removed": [],
+        "changed": [],
+        "columns_added": [],
+        "columns_removed": [],
+    }
+    
+    try:
+        # Extract column information from both files
+        logger.debug("Streaming sorted: Extracting column information")
+        prev_columns = set()
+        curr_columns = set()
+        
+        with open(prev_path, newline='', encoding=encoding) as f:
+            reader = csv.reader(f, dialect=dialect)
+            prev_headings = next(reader, None)
+            if not prev_headings:
+                logger.error("Previous file has no headers")
+                raise ValueError("Previous file has no headers")
+            prev_columns = set(prev_headings)
+            if key not in prev_headings:
+                logger.error(f"Key column '{key}' not found in previous file")
+                raise ValueError(f"Key column '{key}' not found in previous file")
+            prev_key_index = prev_headings.index(key)
+        
+        with open(curr_path, newline='', encoding=encoding) as f:
+            reader = csv.reader(f, dialect=dialect)
+            curr_headings = next(reader, None)
+            if not curr_headings:
+                logger.error("Current file has no headers")
+                raise ValueError("Current file has no headers")
+            curr_columns = set(curr_headings)
+            if key not in curr_headings:
+                logger.error(f"Key column '{key}' not found in current file")
+                raise ValueError(f"Key column '{key}' not found in current file")
+            curr_key_index = curr_headings.index(key)
+            
+        # Determine columns to compare
+        compare_columns_set, ignore_columns = _determine_columns_to_compare(
+            prev_columns, curr_columns, compare_columns, ignorefields
+        )
+        
+        # Identify column changes
+        result["columns_added"] = [c for c in curr_columns if c not in prev_columns and c in compare_columns_set]
+        result["columns_removed"] = [c for c in prev_columns if c not in curr_columns and c in compare_columns_set]
+        
+        # Open both files and compare row by row
+        logger.debug("Streaming sorted: Starting row comparison")
+        with open(prev_path, newline='', encoding=encoding) as prev_file, \
+             open(curr_path, newline='', encoding=encoding) as curr_file:
+            
+            # Skip headers
+            prev_reader = csv.reader(prev_file, dialect=dialect)
+            curr_reader = csv.reader(curr_file, dialect=dialect)
+            prev_headers = next(prev_reader)
+            curr_headers = next(curr_reader)
+            
+            # Set up dictionaries to convert row arrays to dicts
+            prev_indices = {name: i for i, name in enumerate(prev_headers)}
+            curr_indices = {name: i for i, name in enumerate(curr_headers)}
+            
+            # Get initial rows
+            try:
+                prev_row_data = next(prev_reader, None)
+                curr_row_data = next(curr_reader, None)
+            except csv.Error as e:
+                logger.error(f"CSV error: {str(e)}")
+                raise
+            
+            # Process row by row in sorted order
+            while prev_row_data is not None or curr_row_data is not None:
+                # Convert row arrays to dicts
+                prev_row = {name: prev_row_data[i].strip() if prev_row_data else None 
+                           for name, i in prev_indices.items()} if prev_row_data else None
+                curr_row = {name: curr_row_data[i].strip() if curr_row_data else None 
+                           for name, i in curr_indices.items()} if curr_row_data else None
+                
+                # Compare keys
+                prev_key_val = prev_row[key] if prev_row else None
+                curr_key_val = curr_row[key] if curr_row else None
+                
+                if prev_row is None:
+                    # Current row only - add it to added list
+                    result["added"].append(curr_row)
+                    curr_row_data = next(curr_reader, None)
+                elif curr_row is None:
+                    # Previous row only - add it to removed list
+                    result["removed"].append(prev_row)
+                    prev_row_data = next(prev_reader, None)
+                elif prev_key_val < curr_key_val:
+                    # Previous row is removed
+                    result["removed"].append(prev_row)
+                    prev_row_data = next(prev_reader, None)
+                elif prev_key_val > curr_key_val:
+                    # Current row is added
+                    result["added"].append(curr_row)
+                    curr_row_data = next(curr_reader, None)
+                else:
+                    # Same key - check for changes
+                    changed_fields = {
+                        col: [prev_row.get(col), curr_row.get(col)]
+                        for col in compare_columns_set
+                        if col in prev_columns and col in curr_columns and prev_row.get(col) != curr_row.get(col)
+                    }
+                    
+                    if changed_fields:
+                        result["changed"].append({
+                            "key": prev_key_val,
+                            "changes": changed_fields
+                        })
+                    
+                    # Move to next rows
+                    prev_row_data = next(prev_reader, None)
+                    curr_row_data = next(curr_reader, None)
+    
+    except (csv.Error, UnicodeDecodeError) as e:
+        logger.error(f"CSV processing error: {str(e)}")
+        raise
+        
+    return result
+
+def is_key_column_sorted(file_path, key, encoding='utf-8', dialect='excel', sample_size=1000):
+    """
+    Check if the key column in a CSV file is sorted in ascending order.
+    
+    Args:
+        file_path: Path to the CSV file
+        key: Column name to check for sorting
+        encoding: File encoding (default: utf-8)
+        dialect: CSV dialect (default: excel)
+        sample_size: Number of rows to check (default: 1000)
+        
+    Returns:
+        Boolean indicating whether the key column appears to be sorted
+    """
+    try:
+        with open(file_path, newline='', encoding=encoding) as f:
+            reader = csv.reader(f, dialect=dialect)
+            headers = next(reader, None)
+            if not headers:
+                return False
+                
+            if key not in headers:
+                return False
+                
+            key_index = headers.index(key)
+            
+            # Read the first row
+            prev_row = next(reader, None)
+            if not prev_row:
+                return True  # Only header row exists, technically sorted
+                
+            prev_key_value = prev_row[key_index].strip()
+            count = 0
+            
+            # Check subsequent rows to see if they're sorted
+            for row in reader:
+                curr_key_value = row[key_index].strip()
+                
+                # If current key is less than previous, not sorted
+                if curr_key_value < prev_key_value:
+                    return False
+                    
+                prev_key_value = curr_key_value
+                count += 1
+                
+                # Check only a sample to avoid reading entire file
+                if count >= sample_size:
+                    break
+                    
+            return True
+            
+    except Exception as e:
+        logger.warning(f"Error checking if key column is sorted: {str(e)}")
+        return False
+
+def human_text(diff, key=None, current=None):
+    """
+    Convert a diff dictionary to human-readable text.
+    
+    Args:
+        diff: Dictionary containing diff information
+        key: Key column name for reference in output (optional)
+        current: Dictionary of current data (optional, used for unchanged fields)
+        
+    Returns:
+        Human-readable string representation of the diff
+    """
+    lines = []
+    
+    # Summary counts
+    summary = []
+    if diff["columns_added"]:
+        summary.append(f"{len(diff['columns_added'])} columns added")
+    if diff["columns_removed"]:
+        summary.append(f"{len(diff['columns_removed'])} columns removed")
+    if diff["changed"]:
+        summary.append(f"{len(diff['changed'])} rows changed")
+    if diff["added"]:
+        summary.append(f"{len(diff['added'])} rows added")
+    if diff["removed"]:
+        summary.append(f"{len(diff['removed'])} rows removed")
+        
+    if summary:
+        lines.append(", ".join(summary))
+    else:
+        lines.append("No differences found")
+    
+    lines.append("")  # Blank line after summary
+    
+    # Column changes
+    if diff["columns_added"]:
+        lines.append(f"{len(diff['columns_added'])} columns added")
+        lines.append("")
+        for col in sorted(diff["columns_added"]):
+            lines.append(f"  {col}")
+        lines.append("")
+    
+    if diff["columns_removed"]:
+        lines.append(f"{len(diff['columns_removed'])} columns removed")
+        lines.append("")
+        for col in sorted(diff["columns_removed"]):
+            lines.append(f"  {col}")
+        lines.append("")
+    
+    # Changed rows
+    if diff["changed"]:
+        lines.append(f"{len(diff['changed'])} rows changed")
+        lines.append("")
+        
+        for change in diff["changed"]:
+            change_key = change["key"]
+            lines.append(f"  {key}: {change_key}")
+            
+            # Show changed fields
+            for field, (old, new) in sorted(change["changes"].items()):
+                # Format values for display
+                old_str = f'"{old}"' if isinstance(old, str) else str(old)
+                new_str = f'"{new}"' if isinstance(new, str) else str(new)
+                lines.append(f"    {field}: {old_str} => {new_str}")
+            
+            # Show unchanged fields if present
+            if "unchanged" in change and change["unchanged"]:
+                lines.append("    (unchanged fields)")
+                for field, value in sorted(change["unchanged"].items()):
+                    value_str = f'"{value}"' if isinstance(value, str) else str(value)
+                    lines.append(f"    {field}: {value_str}")
+            
+            lines.append("")  # Blank line between changes
+    
+    # Added rows
+    if diff["added"]:
+        lines.append(f"{len(diff['added'])} rows added")
+        lines.append("")
+        
+        for row in diff["added"]:
+            key_val = row.get(key, "unknown") if key else "unknown"
+            lines.append(f"  {key}: {key_val}")
+            
+            # Show all fields except the key
+            for field, value in sorted(row.items()):
+                if field != key:
+                    value_str = f'"{value}"' if isinstance(value, str) else str(value)
+                    lines.append(f"  {field}: {value_str}")
+            
+            lines.append("")  # Blank line between rows
+    
+    # Removed rows
+    if diff["removed"]:
+        lines.append(f"{len(diff['removed'])} rows removed")
+        lines.append("")
+        
+        for row in diff["removed"]:
+            key_val = row.get(key, "unknown") if key else "unknown"
+            lines.append(f"  {key}: {key_val}")
+            
+            # Show all fields except the key
+            for field, value in sorted(row.items()):
+                if field != key:
+                    value_str = f'"{value}"' if isinstance(value, str) else str(value)
+                    lines.append(f"  {field}: {value_str}")
+            
+            lines.append("")  # Blank line between rows
+    
+    return "\n".join(lines)
diff --git a/db_diff/cli.py b/db_diff/cli.py
new file mode 100644
index 0000000..b84373b
--- /dev/null
+++ b/db_diff/cli.py
@@ -0,0 +1,771 @@
+"""
+Command-line interface for db-diff tool.
+
+This module provides the command-line interface for comparing CSV and JSON files.
+"""
+
+#------------------------------------------------------------------------------
+# IMPORTS
+#------------------------------------------------------------------------------
+# Standard library imports
+import csv
+import datetime
+import json
+import logging
+import os
+import shutil
+import time
+import traceback
+from typing import Any, Callable, Dict, Iterator, List, Optional, Set, Tuple, Union
+
+# Third-party imports
+import click
+
+# Local imports
+from . import (
+    compare,
+    human_text,
+    is_key_column_sorted,
+    load_csv,
+    load_json,
+    logger,
+    streaming_compare_csv,
+    streaming_compare_csv_sorted
+)
+
+#------------------------------------------------------------------------------
+# LOGGING SETUP
+#------------------------------------------------------------------------------
+logger = logging.getLogger("db_diff")
+
+# Get terminal width for better help formatting
+TERMINAL_WIDTH = shutil.get_terminal_size().columns
+
+#------------------------------------------------------------------------------
+# CONSTANTS
+#------------------------------------------------------------------------------
+# Known CSV dialects
+DIALECTS = {
+    "csv": "excel",
+    "tsv": "excel-tab",
+}
+
+# Supported input formats
+SUPPORTED_FORMATS = ("csv", "tsv", "json")
+
+#------------------------------------------------------------------------------
+# FILE DETECTION AND LOADING FUNCTIONS
+#------------------------------------------------------------------------------
+def sniff_key_column(filename: str, input_format: str, encoding: str = "utf-8") -> Optional[str]:
+    """
+    Detect the key column from the first row of a CSV/TSV/JSON file.
+    
+    Args:
+        filename: Path to the input file
+        input_format: Format of the input file (csv, tsv, json)
+        encoding: File encoding (default: utf-8)
+        
+    Returns:
+        The name of the first column or key, or None if detection fails
+        
+    Raises:
+        click.ClickException: If key detection fails
+    """
+    logger.debug(f"Sniffing key column from {filename} with format {input_format}")
+    try:
+        if input_format == "json":
+            with open(filename, encoding=encoding) as f:
+                data = json.load(f)
+                if isinstance(data, list) and data:
+                    first_key = list(data[0].keys())[0]
+                    logger.debug(f"Found first key in JSON: {first_key}")
+                    return first_key
+                else:
+                    raise click.ClickException("JSON file does not contain a list of objects with keys")
+        else:
+            with open(filename, newline="", encoding=encoding) as f:
+                reader = csv.reader(f, dialect=DIALECTS.get(input_format, "excel"))
+                headings = next(reader, None)
+                if headings and len(headings) > 0:
+                    logger.debug(f"Found first column in CSV/TSV: {headings[0]}")
+                    return headings[0]
+                else:
+                    raise click.ClickException("No header row found in CSV/TSV file")
+    except Exception as e:
+        logger.error(f"Error sniffing key column: {str(e)}")
+        raise click.ClickException(f"Could not sniff key column: {e}")
+    return None
+
+def load(filename: str, key: str, input_format: str, encoding: str = "utf-8") -> Dict:
+    """
+    Load a CSV/TSV/JSON file into a dictionary keyed by the given column.
+    
+    Args:
+        filename: Path to the input file
+        key: Column name to use as key
+        input_format: Format of the input file (csv, tsv, json)
+        encoding: File encoding (default: utf-8)
+        
+    Returns:
+        Dictionary of data rows keyed by the specified column
+        
+    Raises:
+        click.ClickException: If loading fails
+    """
+    logger.debug(f"Loading file {filename} with format {input_format}, key={key}")
+    try:
+        if input_format == "json":
+            with open(filename, encoding=encoding) as f:
+                try:
+                    data = load_json(f, key=key)
+                    logger.debug(f"Loaded {len(data)} rows from JSON file")
+                    return data
+                except ValueError as e:
+                    raise click.ClickException(f"Invalid JSON format in '{filename}': {e}")
+                except KeyError as e:
+                    raise click.ClickException(f"Key error in JSON file '{filename}': {e}")
+        else:
+            with open(filename, newline="", encoding=encoding) as f:
+                try:
+                    data = load_csv(f, key=key, dialect=DIALECTS.get(input_format, "excel"))
+                    logger.debug(f"Loaded {len(data)} rows from CSV/TSV file")
+                    return data
+                except csv.Error as e:
+                    raise click.ClickException(f"CSV parsing error in '{filename}': {e}")
+                except KeyError as e:
+                    raise click.ClickException(f"Key column '{key}' not found in all rows in '{filename}'")
+    except FileNotFoundError:
+        raise click.ClickException(f"File not found: '{filename}'")
+    except PermissionError:
+        raise click.ClickException(f"Permission denied when accessing '{filename}'")
+    except UnicodeDecodeError:
+        raise click.ClickException(f"Failed to decode '{filename}' with encoding '{encoding}'. Try a different encoding.")
+    except Exception as e:
+        logger.error(f"Error loading file {filename}: {str(e)}")
+        raise click.ClickException(f"Failed to load '{filename}': {e}")
+
+#------------------------------------------------------------------------------
+# OUTPUT AND VALIDATION FUNCTIONS
+#------------------------------------------------------------------------------
+def generate_output_filename(previous: str, current: str, output_file: Optional[str], output_path: Optional[str], timestamp: bool = False) -> str:
+    """
+    Generate an output filename for the diff result.
+    
+    Args:
+        previous: Path to the previous file
+        current: Path to the current file
+        output_file: User-specified output filename (optional)
+        output_path: User-specified output directory (optional)
+        timestamp: Whether to include timestamp in filename (default: False)
+        
+    Returns:
+        Full path to the output file
+        
+    Raises:
+        click.ClickException: If the output path doesn't exist
+    """
+    # Extract base filenames without extensions for the comparison files
+    prev_basename = os.path.splitext(os.path.basename(previous))[0]
+    curr_basename = os.path.splitext(os.path.basename(current))[0]
+    
+    # Build the base filename
+    if output_file:
+        # Remove .json extension if present to avoid double extension
+        if output_file.lower().endswith('.json'):
+            output_file = output_file[:-5]
+        base_filename = f"diffs_{output_file}.json"
+    else:
+        # Default output filename
+        base_filename = f"diffs_{prev_basename}__vs__{curr_basename}.json"
+    
+    # Add timestamp prefix if enabled
+    if timestamp:
+        timestamp_str = datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
+        base_filename = f"{timestamp_str}_{base_filename}"
+    
+    # Resolve the final path
+    if output_path:
+        if not os.path.isdir(output_path):
+            logger.error(f"Output path '{output_path}' does not exist")
+            raise click.ClickException(f"Output path '{output_path}' does not exist or is not a directory.")
+        full_path = os.path.join(output_path, base_filename)
+    else:
+        full_path = base_filename
+    
+    logger.debug(f"Generated output filename: {full_path}")
+    return full_path
+
+def validate_options(
+    fields: Optional[str], 
+    ignorefields: Optional[str], 
+    input_format: Optional[str], 
+    encoding: str, 
+    output: str, 
+    output_file: Optional[str], 
+    output_path: Optional[str]
+) -> None:
+    """
+    Validate command-line options for compatibility and correctness.
+    
+    Args:
+        fields: Comma-separated list of fields to compare
+        ignorefields: Comma-separated list of fields to ignore
+        input_format: Input file format
+        encoding: File encoding
+        output: Output format
+        output_file: Output filename
+        output_path: Output directory path
+        
+    Raises:
+        click.ClickException: If options are invalid or incompatible
+    """
+    # Validate mutually exclusive options
+    if fields and ignorefields:
+        raise click.ClickException("You cannot use --fields and --ignorefields at the same time.")
+    
+    # Validate input format
+    if input_format and input_format not in SUPPORTED_FORMATS:
+        raise click.ClickException(
+            f"Unsupported format: {input_format}. "
+            f"Supported formats: {', '.join(SUPPORTED_FORMATS)}"
+        )
+    
+    # Validate encoding (try to decode a simple string to verify)
+    if encoding:
+        try:
+            "test".encode().decode(encoding)
+        except LookupError:
+            raise click.ClickException(f"Unknown encoding: {encoding}")
+    
+    # Check output path exists if specified
+    if output_path and not os.path.isdir(output_path):
+        raise click.ClickException(f"Output path '{output_path}' does not exist or is not a directory.")
+    
+    # Check if output file is specified when using jsonfile output
+    if output == "jsonfile" and not output_file and not output_path:
+        logger.warning("No output filename or path specified for jsonfile output, using default.")
+        
+    # For fields and ignorefields, check for empty fields
+    if fields:
+        field_list = [f.strip() for f in fields.split(",")]
+        if "" in field_list:
+            raise click.ClickException("Empty field name in --fields list")
+    
+    if ignorefields:
+        ignored_fields = [f.strip() for f in ignorefields.split(",")]
+        if "" in ignored_fields:
+            raise click.ClickException("Empty field name in --ignorefields list")
+
+def list_available_fields(filename: str, detected_format: str, encoding: str) -> None:
+    """
+    List available fields in the input file and exit.
+    
+    Args:
+        filename: Path to the input file
+        detected_format: Format of the input file
+        encoding: File encoding
+    
+    Raises:
+        click.ClickException: If listing fields fails
+    """
+    try:
+        if detected_format == "json":
+            with open(filename, encoding=encoding) as f:
+                data = json.load(f)
+                if isinstance(data, list) and data:
+                    all_fields = set()
+                    # Collect all unique fields across the first 10 records
+                    for i, item in enumerate(data[:10]):
+                        all_fields.update(item.keys())
+                    
+                    click.echo(f"Available fields in '{os.path.basename(filename)}' ({len(all_fields)} total):")
+                    for field in sorted(all_fields):
+                        click.echo(f"  {field}")
+                else:
+                    click.echo("No fields found: JSON file does not contain a list of objects")
+        else:
+            with open(filename, newline="", encoding=encoding) as f:
+                reader = csv.reader(f, dialect=DIALECTS.get(detected_format, "excel"))
+                headings = next(reader, None)
+                if headings:
+                    click.echo(f"Available fields in '{os.path.basename(filename)}' ({len(headings)} total):")
+                    for field in headings:
+                        click.echo(f"  {field}")
+                else:
+                    click.echo("No fields found: CSV file appears to be empty")
+    except Exception as e:
+        logger.error(f"Error listing fields: {str(e)}")
+        raise click.ClickException(f"Could not list fields: {e}")
+
+#------------------------------------------------------------------------------
+# STREAMING MODE HELPER FUNCTIONS
+#------------------------------------------------------------------------------
+def determine_streaming_mode(
+    streaming: str, 
+    detected_format: str, 
+    previous: str, 
+    current: str, 
+    key: str, 
+    fields: Optional[str],
+    encoding: str,
+    samplesize: int
+) -> Optional[str]:
+    """
+    Determine the appropriate streaming mode based on file properties and user settings.
+    
+    Args:
+        streaming: User-specified streaming mode
+        detected_format: Format of the input files
+        previous: Path to the previous file
+        current: Path to the current file
+        key: Key column name
+        fields: Comma-separated list of fields to compare
+        encoding: File encoding
+        samplesize: Number of rows to sample for sorting check
+        
+    Returns:
+        The streaming mode to use ('normal', 'sortedkey', or None)
+    """
+    if streaming == "off":
+        logger.debug("Streaming mode manually disabled")
+        return None
+    elif streaming in ("normal", "sortedkey"):
+        if detected_format not in ("csv", "tsv"):
+            logger.error("Streaming mode requested but file format is not CSV/TSV")
+            raise click.ClickException("Streaming modes only work with CSV/TSV files.")
+        if streaming == "sortedkey":
+            logger.debug("Using sorted key streaming mode")
+            return "sortedkey"
+        return "normal"
+    elif streaming == "auto":
+        # Auto mode - determine the best streaming mode based on file properties
+        prev_size = os.path.getsize(previous)
+        curr_size = os.path.getsize(current)
+        total_size = prev_size + curr_size
+        
+        # Get available memory
+        try:
+            import psutil
+            available_memory = psutil.virtual_memory().available
+            memory_threshold = available_memory * 0.2  # Use at most 20% of available memory
+            logger.debug(f"Auto streaming mode: Available memory: {available_memory/1024/1024:.1f} MB, File sizes: {prev_size/1024/1024:.1f} MB + {curr_size/1024/1024:.1f} MB")
+        except ImportError:
+            # If psutil is not available, use a fixed threshold
+            memory_threshold = 500 * 1024 * 1024  # 500MB threshold
+            logger.debug(f"Auto streaming mode: psutil not available, using fixed threshold of {memory_threshold/1024/1024:.1f} MB")
+        
+        if detected_format not in ("csv", "tsv"):
+            logger.debug("Auto streaming mode: Not CSV/TSV format, disabling streaming")
+            return None
+        elif fields:  # If specific fields are selected, we need to read everything, so streaming won't help much
+            logger.debug("Auto streaming mode: Field filtering active, disabling streaming")
+            return None
+        elif total_size <= 10*1024*1024:  # Less than 10MB total
+            logger.debug("Auto streaming mode: Small file size, disabling streaming")
+            return None
+        elif total_size > memory_threshold:
+            # Files are too large for memory, use streaming regardless
+            logger.debug("Auto streaming mode: Files too large for memory, forcing streaming mode")
+            
+            # Check if key column is sorted in both files
+            if (is_key_column_sorted(previous, key, encoding=encoding, dialect=DIALECTS.get(detected_format, "excel"), sample_size=samplesize) and
+                is_key_column_sorted(current, key, encoding=encoding, dialect=DIALECTS.get(detected_format, "excel"), sample_size=samplesize)):
+                logger.info("Auto streaming mode: Key columns appear to be sorted, using sorted key streaming mode")
+                click.echo("Key columns appear to be sorted, using fast streaming mode...")
+                return "sortedkey"
+            else:
+                logger.info(f"Auto streaming mode: Large files ({total_size/1024/1024:.1f} MB total), using normal streaming mode")
+                click.echo("Files are large, automatically enabling streaming mode for better memory usage...")
+                return "normal"
+        else:
+            # Files could fit in memory, but check if sorting would be beneficial
+            if (is_key_column_sorted(previous, key, encoding=encoding, dialect=DIALECTS.get(detected_format, "excel"), sample_size=samplesize) and
+                is_key_column_sorted(current, key, encoding=encoding, dialect=DIALECTS.get(detected_format, "excel"), sample_size=samplesize)):
+                logger.info("Auto streaming mode: Key columns appear to be sorted, using sorted key streaming mode")
+                click.echo("Key columns appear to be sorted, using fast streaming mode...")
+                return "sortedkey"
+            elif total_size > memory_threshold * 0.5:  # If using more than 50% of memory threshold
+                logger.info(f"Auto streaming mode: Moderately large files ({total_size/1024/1024:.1f} MB), using normal streaming mode")
+                click.echo("Files are moderately large, using streaming mode for better memory efficiency...")
+                return "normal"
+    
+    return None  # Default if no condition is met
+
+def execute_comparison(
+    comparison_mode: Optional[str],
+    previous: str,
+    current: str,
+    key: str,
+    fields_set: Optional[Set[str]],
+    ignorefields_set: Optional[Set[str]],
+    show_unchanged: bool,
+    encoding: str,
+    detected_format: str
+) -> Dict:
+    """
+    Execute the appropriate comparison method based on the selected mode.
+    
+    Args:
+        comparison_mode: Streaming mode to use ('normal', 'sortedkey', or None)
+        previous: Path to the previous file
+        current: Path to the current file
+        key: Key column name
+        fields_set: Set of fields to compare
+        ignorefields_set: Set of fields to ignore
+        show_unchanged: Whether to show unchanged fields
+        encoding: File encoding
+        detected_format: Format of the input files
+        
+    Returns:
+        Dictionary containing diff results
+    """
+    if comparison_mode == "sortedkey":
+        click.echo("Using sorted key streaming mode for comparison...")
+        return streaming_compare_csv_sorted(
+            previous,
+            current,
+            key=key,
+            compare_columns=fields_set,
+            ignorefields=ignorefields_set,
+            encoding=encoding,
+            dialect=DIALECTS.get(detected_format, "excel"),
+        )
+    elif comparison_mode == "normal":
+        click.echo("Using normal streaming mode for comparison...")
+        return streaming_compare_csv(
+            previous,
+            current,
+            key=key,
+            compare_columns=fields_set,
+            ignorefields=ignorefields_set,
+            encoding=encoding,
+            dialect=DIALECTS.get(detected_format, "excel"),
+        )
+    else:
+        click.echo("Loading previous data...")
+        previous_data = load(previous, key, detected_format, encoding)
+        click.echo(f"Loaded {len(previous_data)} rows from previous file")
+        
+        click.echo("Loading current data...")
+        current_data = load(current, key, detected_format, encoding)
+        click.echo(f"Loaded {len(current_data)} rows from current file")
+        
+        click.echo("Comparing data...")
+        return compare(
+            previous_data,
+            current_data,
+            show_unchanged,
+            fields=fields_set,
+            ignorefields=ignorefields_set,
+        )
+
+def process_diff_output(
+    diff: Dict, 
+    output: str, 
+    output_file: Optional[str], 
+    encoding: str,
+    key: str
+) -> None:
+    """
+    Process and output the diff results in the requested format.
+    
+    Args:
+        diff: Dictionary containing diff results
+        output: Output format ('readable', 'json', or 'jsonfile')
+        output_file: Path to output file (for 'jsonfile' output)
+        encoding: File encoding
+        key: Key column name
+    """
+    if output == "json":
+        click.echo("Generating JSON output...")
+        json_output = json.dumps(diff, indent=2)
+        print(json_output)
+        click.echo("Finished. Output printed as JSON to terminal.")
+    elif output == "jsonfile":
+        click.echo(f"Saving JSON output to {output_file}...")
+        with open(output_file, "w", encoding=encoding) as f:
+            json.dump(diff, f, indent=2)
+        click.echo(f"Finished. Output saved to file: {output_file}")
+    else:
+        click.echo("Generating human-readable output...")
+        human_output = human_text(diff, key)
+        print(human_output)
+        click.echo("Finished. Output printed in readable format to terminal.")
+
+def print_summary(diff: Dict, elapsed_time: float, verbose: bool) -> None:
+    """
+    Print summary information about the comparison results.
+    
+    Args:
+        diff: Dictionary containing diff results
+        elapsed_time: Time taken for the comparison (seconds)
+        verbose: Whether to include verbose output
+    """
+    # Display the elapsed time
+    click.echo(f"\nElapsed time: {elapsed_time:.3f} seconds")
+    
+    # Display summary statistics
+    summary = []
+    for stat_key in ["columns_added", "columns_removed", "added", "removed", "changed"]:
+        if diff[stat_key]:
+            count = len(diff[stat_key])
+            label = stat_key.replace('_', ' ')
+            summary.append(f"{count} {label}")
+    
+    if summary:
+        click.echo(f"Summary: {', '.join(summary)}")
+    else:
+        click.echo("No differences found")
+        
+    # Display memory usage if verbose
+    if verbose:
+        try:
+            import psutil
+            process = psutil.Process(os.getpid())
+            memory_mb = process.memory_info().rss / 1024 / 1024
+            logger.debug(f"Memory usage: {memory_mb:.2f} MB")
+        except ImportError:
+            logger.debug("psutil not available, memory usage stats skipped")
+
+#------------------------------------------------------------------------------
+# COMMAND LINE INTERFACE
+#------------------------------------------------------------------------------
+@click.command(
+    context_settings={
+        "help_option_names": ["-h", "--help"],
+        "max_content_width": TERMINAL_WIDTH
+    },
+    help="Compare two CSV or JSON files and identify differences.",
+    epilog="\nExamples:\n"
+           "\n Basic comparison:\n"
+           "\n     db-diff old.csv new.csv\n"
+           "\n With specific key column:\n"
+           "\n     db-diff old.csv new.csv --key=Id\n"
+           "\n Output to JSON file:\n"
+           "\n     db-diff old.csv new.csv --output=jsonfile --outfilename=diff.json\n"
+           "\n Output with timestamp in filename:\n"
+           "\n     db-diff old.csv new.csv --output=jsonfile --timestamp\n"
+           "\n Compare only specific fields:\n"
+           "\n     db-diff old.csv new.csv --fields=Id,Name,Email\n"
+           "\n Ignore specific fields:\n"
+           "\n     db-diff old.csv new.csv --ignorefields=LastModified,CreatedDate\n"
+           "\n List available fields:\n"
+           "\n     db-diff old.csv new.csv --listfields\n"
+           "\n Use streaming mode for large files:\n"
+           "\n     off (disable streaming), normal (memory efficient), sortedkey (fast with sorted keys), auto (select best mode).\n",
+    no_args_is_help=True,
+)
+@click.argument(
+    "previous",
+    type=click.Path(exists=True, file_okay=True, dir_okay=False, allow_dash=False),
+)
+@click.argument(
+    "current",
+    type=click.Path(exists=True, file_okay=True, dir_okay=False, allow_dash=False),
+)
+@click.option(
+    "--key",
+    type=str,
+    default=None,
+    help="Column to use as a unique ID for each row. [default: first column header]",
+)
+@click.option(
+    "--output",
+    default="readable",
+    show_default=True,
+    type=click.Choice(["readable", "json", "jsonfile"], case_sensitive=False),
+    help="Output format, 'jsonfile' will save output to a file.",
+)
+@click.option(
+    "--outfilename",
+    "output_file",
+    type=str,
+    default=None,
+    help="Specify custom filename. [default: diffs_<filename1__vs__filename2>.json]",
+)
+@click.option(
+    "--outfilepath",
+    "output_path",
+    type=click.Path(file_okay=False, dir_okay=True, writable=True, resolve_path=True),
+    default=None,
+    help="Directory to save the output file. [default: current directory]",
+)
+@click.option(
+    "--fields",
+    type=str,
+    default=None,
+    help="Comma-separated list of fields to compare. Cannot use with --ignorefields.",
+)
+@click.option(
+    "--ignorefields",
+    type=str,
+    default=None,
+    help="Comma-separated list of fields to ignore. Cannot use with --fields.",
+)
+@click.option(
+    "--showunchanged",
+    "show_unchanged",
+    is_flag=True,
+    help="Show ALL fields for changed records, not just the modified fields.",
+)
+@click.option(
+    "--format",
+    "input_format",
+    default=None,
+    type=click.Choice(list(SUPPORTED_FORMATS), case_sensitive=False),
+    help="Input format. [default: auto-detect from file extension]",
+)
+@click.option(
+    "--encoding",
+    type=str,
+    default="utf-8",
+    show_default=True,
+    help="Input file encoding (utf-8|utf-16|latin1|cp1252|ascii|...).",
+)
+@click.option(
+    "--streaming",
+    type=click.Choice(["off", "normal", "sortedkey", "auto"], case_sensitive=False),
+    default="auto",
+    show_default=True,
+    help="Processing mode for large CSV files.",
+)
+@click.option(
+    "--listfields",
+    is_flag=True,
+    help="List available fields in input files and exit.",
+)
+@click.option(
+    "--verbose",
+    is_flag=True,
+    help="Enable verbose output.",
+)
+@click.option(
+    "--timestamp",
+    is_flag=True,
+    help="Add timestamp to filename: YYYY-MM-DD_HH-MM-SS_diffs_<filename>.json",
+)
+@click.option(
+    "--samplesize",
+    type=int,
+    default=1000,
+    show_default=True,
+    help="Number of rows to sample when checking if key columns are sorted.",
+)
+@click.version_option()
+def cli(
+    previous: str, 
+    current: str, 
+    key: Optional[str] = None, 
+    input_format: Optional[str] = None,
+    show_unchanged: bool = False, 
+    encoding: str = "utf-8", 
+    output: str = "readable", 
+    output_file: Optional[str] = None, 
+    output_path: Optional[str] = None,
+    fields: Optional[str] = None, 
+    ignorefields: Optional[str] = None, 
+    streaming: str = "auto", 
+    listfields: bool = False, 
+    verbose: bool = False, 
+    timestamp: bool = False,
+    samplesize: int = 1000,
+) -> None:
+    """Compare the differences between two CSV or JSON files."""
+    # Set logging level based on verbosity
+    if verbose:
+        logger.setLevel(logging.DEBUG)
+        click.echo("Verbose mode enabled")
+    else:
+        logger.setLevel(logging.INFO)
+    
+    click.echo(f"Starting diff: '{previous}' vs '{current}'...")
+
+    # Detect file format
+    detected_format = input_format or os.path.splitext(previous)[1].lower().lstrip('.')
+    if detected_format not in SUPPORTED_FORMATS:
+        raise click.ClickException(
+            f"Unsupported file format: {detected_format}. "
+            f"Supported formats are: {', '.join(SUPPORTED_FORMATS)}"
+        )
+
+    # Validate command-line options
+    try:
+        validate_options(fields, ignorefields, detected_format, encoding, output, output_file, output_path)
+    except Exception as e:
+        raise click.ClickException(f"Invalid command-line options: {e}")
+
+    # List fields and exit
+    if listfields:
+        list_available_fields(previous, detected_format, encoding)
+        return
+
+    # Set default output filename if needed
+    if output == "jsonfile":
+        try:
+            output_file = generate_output_filename(previous, current, output_file, output_path, timestamp)
+        except Exception as e:
+            raise click.ClickException(f"Failed to generate output filename: {e}")
+    
+    # Detect key column from the first row of a CSV/TSV/JSON file
+    if key is None:
+        try:
+            key = sniff_key_column(previous, detected_format, encoding)
+            if not key:
+                raise click.ClickException("Could not determine key column from the previous file.")
+            click.echo(f"Using '{key}' as the key column")
+        except Exception as e:
+            logger.error(f"Error detecting key column: {str(e)}")
+            raise click.ClickException(f"Failed to detect key column: {e}. Please specify a key column with --key.")
+
+    # Parse fields/ignorefields into sets
+    fields_set = set(f.strip() for f in fields.split(",")) if fields else None
+    ignorefields_set = set(f.strip() for f in ignorefields.split(",")) if ignorefields else None
+
+    # Always measure time
+    start = time.time()
+
+    try:
+        # Determine comparison method
+        streaming_mode = determine_streaming_mode(
+            streaming, 
+            detected_format, 
+            previous, 
+            current, 
+            key, 
+            fields,
+            encoding,
+            samplesize
+        )
+        
+        # Execute the appropriate comparison method
+        diff = execute_comparison(
+            streaming_mode,
+            previous,
+            current,
+            key,
+            fields_set,
+            ignorefields_set,
+            show_unchanged,
+            encoding,
+            detected_format
+        )
+
+        # Generate output
+        process_diff_output(diff, output, output_file, encoding, key)
+    
+    except Exception as e:
+        logger.error(f"Error during comparison: {str(e)}")
+        if verbose:
+            error_traceback = traceback.format_exc()
+            logger.debug(f"Full traceback:\n{error_traceback}")
+        raise click.ClickException(str(e))
+
+    # Calculate elapsed time
+    end = time.time()
+    elapsed_time = end - start
+    
+    # Print summary
+    print_summary(diff, elapsed_time, verbose)
+
+if __name__ == "__main__":
+    cli()
\ No newline at end of file
diff --git a/setup.py b/setup.py
index 117ed54..b415345 100644
--- a/setup.py
+++ b/setup.py
@@ -2,8 +2,7 @@
 import io
 import os
 
-VERSION = "1.2"
-
+VERSION = "4.2"
 
 def get_long_description():
     with io.open(
@@ -12,32 +11,45 @@ def get_long_description():
     ) as fp:
         return fp.read()
 
-
 setup(
-    name="csv-diff",
-    description="Python CLI tool and library for diffing CSV and JSON files",
+    name="db-diff",
+    description="Python CLI tool and library for comparing CSV/TSV/JSON database dumps and finding differences.",
     long_description=get_long_description(),
     long_description_content_type="text/markdown",
-    author="Simon Willison",
+    author="Thomas Coyle",
     version=VERSION,
     license="Apache License, Version 2.0",
     packages=find_packages(),
-    install_requires=["click", "dictdiffer"],
-    setup_requires=["pytest-runner"],
-    extras_require={"test": ["pytest"]},
-    entry_points="""
-        [console_scripts]
-        csv-diff=csv_diff.cli:cli
-    """,
-    tests_require=["csv-diff[test]"],
-    url="https://github.com/simonw/csv-diff",
+    install_requires=[
+        "click>=8.0.0",
+        "dictdiffer>=0.9.0",
+        "xxhash>=3.0.0",
+        "psutil>=5.9.0"
+    ],
+    entry_points={
+        "console_scripts": [
+            "db-diff=db_diff.cli:cli"
+        ]
+    },
+    url="https://github.com/datsom1/db-diff",
+    python_requires=">=3.6",
     classifiers=[
-        "Development Status :: 4 - Beta",
+        "Development Status :: 5 - Production/Stable",
         "Intended Audience :: Developers",
         "Intended Audience :: Science/Research",
         "Intended Audience :: End Users/Desktop",
         "License :: OSI Approved :: Apache Software License",
         "Programming Language :: Python :: 3.6",
         "Programming Language :: Python :: 3.7",
+        "Programming Language :: Python :: 3.8",
+        "Programming Language :: Python :: 3.9",
+        "Programming Language :: Python :: 3.10",
+        "Programming Language :: Python :: 3.11",
+        "Programming Language :: Python :: 3.12",
+        "Operating System :: OS Independent",
+        "Topic :: Utilities",
+        "Topic :: Software Development :: Libraries :: Python Modules",
     ],
+    include_package_data=True,
+    zip_safe=False,
 )
diff --git a/tests/__init__.py b/tests/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/tests/test_cli.py b/tests/test_cli.py
deleted file mode 100644
index 12f4e12..0000000
--- a/tests/test_cli.py
+++ /dev/null
@@ -1,302 +0,0 @@
-from click.testing import CliRunner
-from csv_diff import cli, load_csv
-import csv
-import pytest
-from .test_csv_diff import ONE, ONE_TSV, TWO, TWO_TSV, THREE, FIVE
-import io
-import json
-from textwrap import dedent
-
-
-@pytest.fixture
-def tsv_files(tmpdir):
-    one = tmpdir / "one.tsv"
-    one.write(ONE_TSV)
-    two = tmpdir / "two.tsv"
-    two.write(TWO_TSV)
-    return str(one), str(two)
-
-
-@pytest.fixture
-def json_files(tmpdir):
-    one = tmpdir / "one.json"
-    one.write(
-        json.dumps(
-            [
-                {"id": 1, "name": "Cleo", "nested": {"foo": 3}, "extra": 1},
-                {"id": 2, "name": "Pancakes", "nested": {"foo": 3}},
-            ]
-        )
-    )
-    two = tmpdir / "two.json"
-    two.write(
-        json.dumps(
-            [
-                {"id": 1, "name": "Cleo", "nested": {"foo": 3, "bar": 5}, "extra": 1},
-                {"id": 2, "name": "Pancakes!", "nested": {"foo": 3}, "extra": 1},
-            ]
-        )
-    )
-    return str(one), str(two)
-
-
-def test_human_cli(tmpdir):
-    one = tmpdir / "one.csv"
-    one.write(ONE)
-    two = tmpdir / "two.csv"
-    two.write(TWO)
-    result = CliRunner().invoke(cli.cli, [str(one), str(two), "--key", "id"])
-    assert 0 == result.exit_code
-    assert (
-        dedent(
-            """
-    1 row changed
-
-      id: 1
-        age: "4" => "5"
-    """
-        ).strip()
-        == result.output.strip()
-    )
-
-
-def test_human_cli_alternative_names(tmpdir):
-    one = tmpdir / "one.csv"
-    one.write(ONE)
-    five = tmpdir / "five.csv"
-    five.write(FIVE)
-    result = CliRunner().invoke(
-        cli.cli,
-        [str(one), str(five), "--key", "id", "--singular", "tree", "--plural", "trees"],
-    )
-    assert 0 == result.exit_code, result.output
-    assert (
-        dedent(
-            """
-    1 tree changed, 2 trees added
-
-    1 tree changed
-
-      id: 1
-        age: "4" => "5"
-
-    2 trees added
-
-      id: 3
-      name: Bailey
-      age: 1
-
-      id: 4
-      name: Carl
-      age: 7
-    """
-        ).strip()
-        == result.output.strip()
-    )
-
-
-def test_human_cli_json(tmpdir):
-    one = tmpdir / "one.csv"
-    one.write(ONE)
-    two = tmpdir / "two.csv"
-    two.write(TWO)
-    result = CliRunner().invoke(cli.cli, [str(one), str(two), "--key", "id", "--json"])
-    assert 0 == result.exit_code
-    assert {
-        "added": [],
-        "removed": [],
-        "changed": [{"key": "1", "changes": {"age": ["4", "5"]}}],
-        "columns_added": [],
-        "columns_removed": [],
-    } == json.loads(result.output.strip())
-
-
-def test_tsv_files(tsv_files):
-    one, two = tsv_files
-    result = CliRunner().invoke(
-        cli.cli, [one, two, "--key", "id", "--json", "--format", "tsv"]
-    )
-    assert 0 == result.exit_code
-    assert {
-        "added": [],
-        "removed": [],
-        "changed": [{"key": "1", "changes": {"age": ["4", "5"]}}],
-        "columns_added": [],
-        "columns_removed": [],
-    } == json.loads(result.output.strip())
-
-
-def test_json_files(json_files):
-    one, two = json_files
-    result = CliRunner().invoke(
-        cli.cli,
-        [one, two, "--key", "id", "--json", "--format", "json"],
-        catch_exceptions=False,
-    )
-    assert 0 == result.exit_code
-    assert {
-        "added": [],
-        "removed": [],
-        "changed": [
-            {"key": 1, "changes": {"nested": ['{"foo": 3}', '{"foo": 3, "bar": 5}']}},
-            {
-                "key": 2,
-                "changes": {"name": ["Pancakes", "Pancakes!"], "extra": [None, 1]},
-            },
-        ],
-        "columns_added": [],
-        "columns_removed": [],
-    } == json.loads(result.output.strip())
-
-
-def test_sniff_format(tsv_files):
-    one, two = tsv_files
-    result = CliRunner().invoke(cli.cli, [one, two, "--key", "id", "--json"])
-    assert 0 == result.exit_code
-    assert {
-        "added": [],
-        "removed": [],
-        "changed": [{"key": "1", "changes": {"age": ["4", "5"]}}],
-        "columns_added": [],
-        "columns_removed": [],
-    } == json.loads(result.output.strip())
-
-
-def test_format_overrides_sniff(tsv_files):
-    one, two = tsv_files
-    result = CliRunner().invoke(
-        cli.cli, [one, two, "--key", "id", "--json", "--format", "csv"]
-    )
-    assert 1 == result.exit_code
-
-
-def test_column_containing_dot(tmpdir):
-    # https://github.com/simonw/csv-diff/issues/7
-    one = tmpdir / "one.csv"
-    two = tmpdir / "two.csv"
-    one.write(
-        dedent(
-            """
-    id,foo.bar,foo.baz
-    1,Dog,Cat
-    """
-        ).strip()
-    )
-    two.write(
-        dedent(
-            """
-    id,foo.bar,foo.baz
-    1,Dog,Beaver
-    """
-        ).strip()
-    )
-    result = CliRunner().invoke(
-        cli.cli, [str(one), str(two), "--key", "id", "--json"], catch_exceptions=False
-    )
-    assert 0 == result.exit_code
-    assert {
-        "added": [],
-        "removed": [],
-        "changed": [{"key": "1", "changes": {"foo.baz": ["Cat", "Beaver"]}}],
-        "columns_added": [],
-        "columns_removed": [],
-    } == json.loads(result.output.strip())
-
-
-def test_semicolon_delimited(tmpdir):
-    # https://github.com/simonw/csv-diff/issues/6
-    one = tmpdir / "one.csv"
-    two = tmpdir / "two.csv"
-    one.write(
-        dedent(
-            """
-    id;name
-    1;Mark
-    """
-        ).strip()
-    )
-    two.write(
-        dedent(
-            """
-    id;name
-    1;Brian
-    """
-        ).strip()
-    )
-    result = CliRunner().invoke(
-        cli.cli, [str(one), str(two), "--key", "id", "--json"], catch_exceptions=False
-    )
-    assert 0 == result.exit_code
-    assert {
-        "added": [],
-        "removed": [],
-        "changed": [{"key": "1", "changes": {"name": ["Mark", "Brian"]}}],
-        "columns_added": [],
-        "columns_removed": [],
-    } == json.loads(result.output.strip())
-
-
-def test_diff_with_extras(tmpdir):
-    one = tmpdir / "one.json"
-    two = tmpdir / "two.json"
-    one.write(
-        json.dumps(
-            [
-                {"id": 1, "name": "Cleo", "type": "dog"},
-                {"id": 2, "name": "Suna", "type": "chicken"},
-            ]
-        )
-    )
-    two.write(
-        json.dumps(
-            [
-                {"id": 2, "name": "Suna", "type": "pretty chicken"},
-                {"id": 3, "name": "Artie", "type": "bunny"},
-            ]
-        )
-    )
-    result = CliRunner().invoke(
-        cli.cli,
-        [
-            str(one),
-            str(two),
-            "--key",
-            "id",
-            "--format",
-            "json",
-            "--extra",
-            "search",
-            "https://www.google.com/search?q={name}",
-        ],
-        catch_exceptions=False,
-    )
-    assert result.exit_code == 0
-    expected = dedent(
-        """
-    1 row changed, 1 row added, 1 row removed
-
-    1 row changed
-
-      id: 2
-        type: "chicken" => "pretty chicken"
-      extras:
-        search: https://www.google.com/search?q=Suna
-
-    1 row added
-
-      id: 3
-      name: Artie
-      type: bunny
-      extras:
-        search: https://www.google.com/search?q=Artie
-
-    1 row removed
-
-      id: 1
-      name: Cleo
-      type: dog
-      extras:
-        search: https://www.google.com/search?q=Cleo
-    """
-    ).strip()
-    assert result.output.strip() == expected
diff --git a/tests/test_csv_diff.py b/tests/test_csv_diff.py
deleted file mode 100644
index 0e3670f..0000000
--- a/tests/test_csv_diff.py
+++ /dev/null
@@ -1,117 +0,0 @@
-from csv_diff import load_csv, compare
-import io
-
-ONE = """id,name,age
-1,Cleo,4
-2,Pancakes,2"""
-
-ONE_TSV = """id\tname\tage
-1\tCleo\t4
-2\tPancakes\t2"""
-
-TWO = """id,name,age
-1,Cleo,5
-2,Pancakes,2"""
-
-TWO_TSV = """id\tname\tage
-1\tCleo\t5
-2\tPancakes\t2"""
-
-THREE = """id,name,age
-1,Cleo,5"""
-
-FOUR = """id,name,age
-1,Cleo,5
-2,Pancakes,2,
-3,Bailey,1"""
-
-FIVE = """id,name,age
-1,Cleo,5
-2,Pancakes,2,
-3,Bailey,1
-4,Carl,7"""
-
-SIX = """id,name,age
-1,Cleo,5
-3,Bailey,1"""
-
-SEVEN = """id,name,weight
-1,Cleo,48
-3,Bailey,20"""
-
-EIGHT = """id,name,age,length
-3,Bailee,1,100
-4,Bob,7,422"""
-
-NINE = """id,name,age
-1,Cleo,5
-2,Pancakes,4"""
-
-TEN = """id,name,age
-1,Cleo,5
-2,Pancakes,3"""
-
-
-def test_row_changed():
-    diff = compare(
-        load_csv(io.StringIO(ONE), key="id"), load_csv(io.StringIO(TWO), key="id")
-    )
-    assert {
-        "added": [],
-        "removed": [],
-        "changed": [{"key": "1", "changes": {"age": ["4", "5"]}}],
-        "columns_added": [],
-        "columns_removed": [],
-    } == diff
-
-
-def test_row_added():
-    diff = compare(
-        load_csv(io.StringIO(THREE), key="id"), load_csv(io.StringIO(TWO), key="id")
-    )
-    assert {
-        "changed": [],
-        "removed": [],
-        "added": [{"age": "2", "id": "2", "name": "Pancakes"}],
-        "columns_added": [],
-        "columns_removed": [],
-    } == diff
-
-
-def test_row_removed():
-    diff = compare(
-        load_csv(io.StringIO(TWO), key="id"), load_csv(io.StringIO(THREE), key="id")
-    )
-    assert {
-        "changed": [],
-        "removed": [{"age": "2", "id": "2", "name": "Pancakes"}],
-        "added": [],
-        "columns_added": [],
-        "columns_removed": [],
-    } == diff
-
-
-def test_columns_changed():
-    diff = compare(
-        load_csv(io.StringIO(SIX), key="id"), load_csv(io.StringIO(SEVEN), key="id")
-    )
-    assert {
-        "changed": [],
-        "removed": [],
-        "added": [],
-        "columns_added": ["weight"],
-        "columns_removed": ["age"],
-    } == diff
-
-
-def test_tsv():
-    diff = compare(
-        load_csv(io.StringIO(ONE), key="id"), load_csv(io.StringIO(TWO_TSV), key="id")
-    )
-    assert {
-        "added": [],
-        "removed": [],
-        "changed": [{"key": "1", "changes": {"age": ["4", "5"]}}],
-        "columns_added": [],
-        "columns_removed": [],
-    } == diff
diff --git a/tests/test_human_text.py b/tests/test_human_text.py
deleted file mode 100644
index f1bd381..0000000
--- a/tests/test_human_text.py
+++ /dev/null
@@ -1,224 +0,0 @@
-from csv_diff import load_csv, compare, human_text
-from .test_csv_diff import ONE, TWO, THREE, FOUR, FIVE, SIX, SEVEN, EIGHT, NINE, TEN
-from textwrap import dedent
-import io
-
-
-def test_row_changed():
-    diff = compare(
-        load_csv(io.StringIO(ONE), key="id"), load_csv(io.StringIO(TWO), key="id")
-    )
-    assert (
-        dedent(
-            """
-    1 row changed
-
-      id: 1
-        age: "4" => "5"
-    """
-        ).strip()
-        == human_text(diff, "id")
-    )
-
-
-def test_row_changed_show_unchanged():
-    diff = compare(
-        load_csv(io.StringIO(ONE), key="id"),
-        load_csv(io.StringIO(TWO), key="id"),
-        show_unchanged=True,
-    )
-    assert (
-        dedent(
-            """
-    1 row changed
-
-      id: 1
-        age: "4" => "5"
-
-        Unchanged:
-          name: "Cleo"
-    """
-        ).strip()
-        == human_text(diff, "id")
-    )
-
-
-def test_row_added():
-    diff = compare(
-        load_csv(io.StringIO(THREE), key="id"), load_csv(io.StringIO(TWO), key="id")
-    )
-    assert (
-        dedent(
-            """
-    1 row added
-
-      id: 2
-      name: Pancakes
-      age: 2
-    """
-        ).strip()
-        == human_text(diff, "id")
-    )
-
-
-def test_rows_added():
-    diff = compare(
-        load_csv(io.StringIO(THREE), key="id"), load_csv(io.StringIO(FIVE), key="id")
-    )
-    assert (
-        dedent(
-            """
-    3 rows added
-
-      id: 2
-      name: Pancakes
-      age: 2
-
-      id: 3
-      name: Bailey
-      age: 1
-
-      id: 4
-      name: Carl
-      age: 7
-    """
-        ).strip()
-        == human_text(diff, "id")
-    )
-
-
-def test_row_removed():
-    diff = compare(
-        load_csv(io.StringIO(TWO), key="id"), load_csv(io.StringIO(THREE), key="id")
-    )
-    assert (
-        dedent(
-            """
-    1 row removed
-
-      id: 2
-      name: Pancakes
-      age: 2
-    """
-        ).strip()
-        == human_text(diff, "id")
-    )
-
-
-def test_row_changed_and_row_added_and_row_deleted():
-    "Should have headers for each section here"
-    diff = compare(
-        load_csv(io.StringIO(ONE), key="id"), load_csv(io.StringIO(SIX), key="id")
-    )
-    assert (
-        dedent(
-            """
-    1 row changed, 1 row added, 1 row removed
-
-    1 row changed
-
-      id: 1
-        age: "4" => "5"
-
-    1 row added
-
-      id: 3
-      name: Bailey
-      age: 1
-
-    1 row removed
-
-      id: 2
-      name: Pancakes
-      age: 2
-    """
-        ).strip()
-        == human_text(diff, "id")
-    )
-
-
-def test_columns_changed():
-    diff = compare(
-        load_csv(io.StringIO(SIX), key="id"), load_csv(io.StringIO(SEVEN), key="id")
-    )
-    assert (
-        dedent(
-            """
-    1 column added, 1 column removed
-
-    1 column added
-
-      weight
-
-    1 column removed
-
-      age
-    """
-        ).strip()
-        == human_text(diff, "id")
-    )
-
-
-def test_columns_and_rows_changed():
-    diff = compare(
-        load_csv(io.StringIO(SEVEN), key="id"), load_csv(io.StringIO(EIGHT), key="id")
-    )
-    assert (
-        dedent(
-            """
-    2 columns added, 1 column removed, 1 row changed, 1 row added, 1 row removed
-
-    2 columns added
-
-      age
-      length
-
-    1 column removed
-
-      weight
-
-    1 row changed
-
-      id: 3
-        name: "Bailey" => "Bailee"
-
-    1 row added
-
-      id: 4
-      name: Bob
-      age: 7
-      length: 422
-
-    1 row removed
-
-      id: 1
-      name: Cleo
-      weight: 48
-    """
-        ).strip()
-        == human_text(diff, "id")
-    )
-
-
-def test_no_key():
-    diff = compare(load_csv(io.StringIO(NINE)), load_csv(io.StringIO(TEN)))
-    assert (
-        dedent(
-            """
-        1 row added, 1 row removed
-
-        1 row added
-
-          id: 2
-          name: Pancakes
-          age: 3
-
-        1 row removed
-
-          id: 2
-          name: Pancakes
-          age: 4
-        """
-        ).strip()
-        == human_text(diff)
-    )
