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.
Five yesterday.
Five today.
Looks unchanged. Look closer.
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.
| id | name | plan |
|---|---|---|
| 1 | Ada | free |
| 2 | Ben | NULL |
| 3 | Cy | pro |
| 4 | Dee | free |
| 6 | Finn | NULL |
current_snapshot5 rows
Today · current
All five rows. The row count stayed the same; the customers did not.
| id | name | plan |
|---|---|---|
| 1 | Ada | pro |
| 2 | Ben | free |
| 4 | Dee | free |
| 5 | Eli | free |
| 6 | Finn | NULL |
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;
| 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;
| 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;
| 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
| id | name |
|---|---|
| 5 | Eli |
Preparing the editor…
Your result
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;
| 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.