The export still has five rows. So nothing changed? That is the trap. A customer can disappear while another arrives, and a plan change can hide in plain sight.

Play spot-the-difference below, then build the query that catches every change. You’ll use a full outer join, discover why NULL needs special treatment, and finish with an audit you can actually read: who arrived, who left, and whose details changed.

The row-count trap

Five yesterday.
Five today.

Looks unchanged. Look closer.

Yesterday.parquet
IDCustomerPlan
1Adafree
2BenNULL
3Cypro
4Deefree
5Eli—
6FinnNULL
Today.parquet
IDCustomerPlan
1Adapro
2Benfree
3Cy—
4Deefree
5Elifree
6FinnNULL

Aligned by ID. A dash means the record is absent; NULL means its plan is unknown.

Find all four changes with a join ↓

Your two exports, every row included

Here are the exact files we will compare. Download the Parquet files to use them locally, or run the examples here: the browser writes and reads its own copies. Your computer’s files are never uploaded.

previous_snapshot5 rows

Yesterday · previous

All five rows. NULL means the customer exists, but their plan is unknown.

idnameplan
1Adafree
2BenNULL
3Cypro
4Deefree
6FinnNULL

current_snapshot5 rows

Today · current

All five rows. The row count stayed the same; the customers did not.

idnameplan
1Adapro
2Benfree
4Deefree
5Elifree
6FinnNULL

Define the comparison before writing the join

For this walkthrough, id is a unique, non-NULL customer key. We compare name and plan, exactly and case-sensitively. Two NULL values count as unchanged; a NULL becoming a value counts as changed. Physical row order is irrelevant.

The sample setup creates previous and current views over the two files. On your machine, point those views at your own snapshots:

CREATE VIEW previous AS
SELECT id, name, plan FROM read_parquet('previous_snapshot.parquet');
CREATE VIEW current AS
SELECT id, name, plan FROM read_parquet('current_snapshot.parquet');

DuckDB reads Parquet directly, so you do not need to import either file into a permanent database. Compare explicit columns. An added ingestion timestamp should not make every customer look changed unless that field belongs in your comparison contract.

Check both keys first

A duplicate ID on both sides multiplies rows in the join. A NULL ID also breaks the presence checks we use below. Find those problems before interpreting the diff:

Check both snapshots for invalid keys

SELECT 'previous' AS snapshot, id, count(*) AS occurrences
FROM previous GROUP BY id HAVING id IS NULL OR count(*) > 1
UNION ALL
SELECT 'current', id, count(*)
FROM current GROUP BY id HAVING id IS NULL OR count(*) > 1;
Expected result · sample data0 rows
snapshot id occurrences

No invalid keys found in either snapshot.

Expected result: zero rows. If this query returns anything, stop and resolve the key definition. A composite key may be appropriate; arbitrarily keeping one duplicate can discard a meaningful record.

Try the failure case independently:

Introduce a duplicate and rerun the key check

WITH example(id, name) AS (
  VALUES (1, 'Ada'), (1, 'Ada duplicate'), (2, 'Ben')
)
SELECT id, count(*) AS occurrences
FROM example GROUP BY id
HAVING id IS NULL OR count(*) > 1;
Expected result · sample data1 row
id occurrences
1 2

It returns ID 1 with two occurrences. The same validation belongs in an automated comparison job, where a nonempty result should fail the job instead of being silently ignored.

Classify every changed record

A full outer join retains keys found on either side. Because we checked that stored IDs cannot be NULL, a missing joined ID tells us which snapshot lacks the record:

Find additions, deletions, and changes

SELECT coalesce(p.id, c.id) AS id,
       CASE WHEN p.id IS NULL THEN 'added'
            WHEN c.id IS NULL THEN 'deleted'
            ELSE 'changed' END AS change,
       p.name AS old_name, c.name AS new_name,
       p.plan AS old_plan, c.plan AS new_plan
FROM previous p FULL OUTER JOIN current c ON p.id = c.id
WHERE p.id IS NULL OR c.id IS NULL
   OR p.name IS DISTINCT FROM c.name
   OR p.plan IS DISTINCT FROM c.plan
ORDER BY id;
Expected result · sample data4 rows
id change old_name new_name old_plan new_plan
1 changed Ada Ada free pro
2 changed Ben Ben NULL free
3 deleted Cy NULL pro NULL
5 added NULL Eli NULL free

Scroll for more columns →

The SQL returns NULL for an unknown field and for fields on the missing side of the join. Read change to distinguish them: Ben exists in both snapshots but had no plan; Cy has no current record; Eli has no previous record. IDs 4 and 6 are unchanged and omitted.

Your turn · check your answer

Find just the new customer

Return customers present in current but absent from previous. Your result needs two columns: id and name.

See the expected result
Your target result1 row
id name
5 Eli
Sample data is loaded for you.

Preparing the editor…

Need a hint?

Keep the LEFT JOIN, then test whether the previous row’s ID is NULL. A changed plan does not make a customer new.

Show a solution
SELECT c.id, c.name
FROM current c
LEFT JOIN previous p ON c.id = p.id
WHERE p.id IS NULL
ORDER BY c.id;

For a composite key, join on every key column and validate their combined uniqueness. Make the presence marker an explicitly non-NULL column; do not guess whether a row exists from an optional field such as plan.

Use NULL-safe comparisons

This looks plausible but is wrong for our contract:

WHERE p.plan <> c.plan

Comparisons involving NULL return NULL, which does not pass a WHERE filter. That would lose Ben’s change from NULL to free. Run the expressions side by side:

See why ordinary inequality loses changes

SELECT p.id, p.plan AS old_plan, c.plan AS new_plan,
       p.plan <> c.plan AS ordinary_inequality,
       p.plan IS DISTINCT FROM c.plan AS changed
FROM previous p JOIN current c ON p.id = c.id
ORDER BY p.id;
Expected result · sample data4 rows
id old_plan new_plan ordinary_inequality changed
1 free pro true true
2 NULL free NULL true
4 free free false false
6 NULL NULL NULL false

Scroll for more columns →

IS DISTINCT FROM reports Ben’s change as true and Finn’s NULL-to-NULL pair as false. DuckDB documents the corresponding NULL comparison semantics.

The predicate must list every field you intend to audit. When a new business column arrives, update the contract and the predicate together. Do not allow a schema change to silently shrink what “unchanged” means.

Handle schema changes and larger files deliberately

Inspect both schemas with DESCRIBE SELECT * FROM read_parquet(...) before comparing unfamiliar exports. Align types explicitly when the intended meaning is the same. Decide separately whether a missing column means an error, an intentional schema change, or a default value; automatically filling it with NULL can hide the distinction.

For decimals, timestamps, and floating-point measurements, write down the equality rule. Exact decimal comparison can suit currency; measured floating-point data may require a tolerance. Keep that tolerance in the SQL and the audit output.

Start with the exact join before introducing hashes. Equal hashes are not proof of equal rows, and hashing still requires reading the input. A fingerprint can help a carefully designed workflow, but it does not remove the need to reason about collisions and schema consistency. The Hashfuncs article covers typed fingerprints in more detail.

On larger datasets, project only the needed columns, inspect the plan, and allow for join memory and temporary disk use. Partitioning both exports by the same stable key can make comparisons manageable; a partition based on a field that changes can put the two versions of a record in different places.

To save the result, wrap the diff query in COPY (...) TO 'changes.parquet' (FORMAT PARQUET) on your machine. Preserve the source snapshot identifiers alongside it. You now have an explicit audit of which records changed and how, with the assumptions needed to trust it.