At a K-12 online learning provider, 2025 to now

Proving a copy matches the original

My team relied on copies of the platform's lists of schools, courses and lessons. I built a way to prove that a copy matches the original exactly, and to explain every difference when it does not.

Role
Built the comparison
Time
October 2026 to now
Tools
SQL, BigQuery, Python
Outcome
Four lists proven to match; the two that could not be checked are named, with the reason

Problem

I work at a K-12 online learning provider: schools in the US and abroad partner with us so their students can take courses online. Everything lives in our online platform: which schools take part, which courses they offer, and each course’s chapters and lessons. A copy of that data is kept in a separate database (a “data warehouse”) for reports, and my team kept its own lists too. Nobody had proved that the copies held exactly the same rows as the live platform, so every report built on them carried a small, quiet doubt.

Constraints

  • I could only read both sides, never change them, and only the tables the question needed. No table about people was read.
  • The only things my queries returned were counts, ids (each row’s unique code) and fingerprints.
  • The live platform keeps changing while you compare, so a difference might be timing rather than a mistake.

What I built

A way to compare two lists, written in SQL on BigQuery with some Python around it. Each side is boiled down to two things: how many rows it has, and one “fingerprint” of all its ids in sorted order. The fingerprint is a SHA-256 hash, a short code that changes completely if even one id differs. If the counts and fingerprints match, the lists match. I tried it first on three small courses before comparing anything bigger.

When fingerprints differ, I split the ids into groups by their first character, find the groups that disagree, and narrow down until I reach the exact rows. Each remaining difference gets one of three labels: timing (added or removed after the copy was taken), a filter that one side applies and the other does not, or unexplained. The warehouse can show a table as it was at an earlier moment, so I rebuilt each table as it stood when the live list was taken. That let me prove a difference was timing rather than assume it.

The diagram in words
  1. Live list, then Count and hash.
  2. Warehouse copy, "as of the pull", then Count and hash.
  3. Both, then Equal?
  4. Solid path: Proven match.
  5. Dashed path: Bucket by first character, Drill down, then Timing, Filter, Unexplained.
  6. Unexplained: "stop and look".

How I proved it worked

  • Four lists matched exactly: schools, courses with the team that grades them, chapters in order, and lessons in order.
  • Getting them to match meant finding filters nobody had written down, such as archived courses.
  • Two other lists could not be rebuilt from the tables I was allowed to read, because both depend on tables about people. That is written down, not hidden.
  • The schools list was off by a handful of rows. Every one was a school added or removed within an hour after the copy was taken, so none was unexplained.

I later used the same approach for a different check. Each partner school has a page of grading instructions that should link to the right form for reporting a grading problem. For a target group of schools, I read every school’s live instructions and used the warehouse copy as a second witness; the two agreed on every line. One fingerprint of the text showed that most of the group already had the same notice, and the rest had none. The text was checked where it lived, so none of it was copied out.

Outcome

  • Four lists proven to match; the two that could not be checked are named, with the reason.
  • Every school in the target group now shows the right notice. Most already had it; I added it to the rest myself, one at a time.
  • Another group of schools, outside the target list, had older links; those went to the person who owns the forms to decide.
  • Each weekly refresh of my team’s lists now starts by proving the copy matches.

What I would do next

  • Run the comparison on a schedule and send an alert on the first unexplained difference.
  • Publish the three labels (timing, filter, unexplained) as a short checklist anyone can use to compare two lists.

The demo it pairs with

Gap finder. Paste two lists or use the sample. See a count and a fingerprint for each side, then every difference grouped by its likely reason.

Gap finder

Made-up dataFull screen

Two made-up lists of ids that should agree: an original, and a copy taken from it. Each side comes down to a count and one fingerprint. If the fingerprints differ, the finder splits the ids into groups by first character, finds the groups that disagree, and gives every row that differs its likely reason.

Comparing lists needs JavaScript. The sample's result reads below without it.

Two lists

Start from a sample

The sample: an original list and a copy taken from it at one moment. Each row's history is known: when it was added or removed, and whether it is archived.

Works on the samples, where each row's archived flag is known.

Paste your own lists: one id per line, up to 500 lines in each box. Order, blank lines and spaces around an id are ignored. Case is not. Ctrl and Enter also compares.

What you paste stays on this page. Nothing is stored or sent.

Count and fingerprint

Original

158 rows

Fingerprint b9f9f4712229

Full fingerprintb9f9f47122292030555bcd37eb1d58235fa39508621b7667f2278149be78f381

Copy

151 rows

Fingerprint 2bd564a83a2e

Full fingerprint2bd564a83a2e7a14c464b214202180aa88326616d7a32dd3f2def40c5db53c73

9 rows differ.

The fingerprints differ, so the lists do not match. 4 of 8 groups disagree.

The fingerprint is a SHA-256 hash of the ids in sorted order. One changed id gives a completely different fingerprint.

Groups by first character

Rows in each group, with the start of its fingerprint
GroupOriginalCopyResult
A199d5b07177844c3Disagrees
B2173bf5b18538b0cDisagrees
C27fcf45e27fcf45eAgrees
D21b0e83e21b0e83eAgrees
E18d36e231596f707Disagrees
F26e744a627bab800Disagrees
G1183130811831308Agrees
H159a590e159a590eAgrees

Every difference, by likely reason

Timing4 rows

Proven by rebuilding the original as it stood when the copy was taken.

  • B437Only in the original. Added to the original 31 minutes after the copy was taken.
  • E268Only in the original. Added to the original 54 minutes after the copy was taken.
  • E422Only in the original. Added to the original 44 minutes after the copy was taken.
  • F480Only in the copy. Removed from the original 28 minutes after the copy was taken.

Filter4 rows

A rule one side applies and the other does not.

  • A116Only in the original. Archived. The copy leaves archived rows out, and the original keeps them.
  • A336Only in the original. Archived. The copy leaves archived rows out, and the original keeps them.
  • B278Only in the original. Archived. The copy leaves archived rows out, and the original keeps them.
  • B377Only in the original. Archived. The copy leaves archived rows out, and the original keeps them.

Unexplained1 row

Each one needs a person to look.

  • E520Only in the original. In the original since 24 days before the copy was taken, and not archived. Neither timing nor the filter explains it.