Sitemap
Zia Said

Zia Said

Comparing Thousands of Snowflake Tables in Minutes (for Pennies)

6 min readAug 8, 2026

--

Fingerprint every table with HASH_AGG, diff only the failures with EXCEPT — and watch out for the duplicate-row blind spot.

Press enter or click to view image in full size

The problem

An ingestion pipeline changes, the same source tables get loaded into two Snowflake schemas, and someone has to prove that thousands of tables in schema B are identical to schema A. A typical case: the pipeline switches its file format from CSV to Parquet, each load lands in its own schema, and the switch can’t go live until every Parquet-loaded table is proven identical to its CSV twin.

Diffing every table is slow, expensive, and mostly wasted — the vast majority will match. Use a funnel instead: a cheap test clears the ~90% that are fine; real compute goes only to the tables that differ.

Stage 1  (cheap, all tables)          Stage 2  (expensive, mismatches only)
┌────────────────────────────┐ ┌─────────────────────────────────┐
│ HASH_AGG fingerprint per │ │ EXCEPT / MINUS in both │
│ table pair + row counts │ ───► │ directions to count and │
│ batched + parallelized │ │ inspect the differing rows │
└────────────────────────────┘ └─────────────────────────────────┘
thousands of pairs only the failures

Representative numbers from a real run (rounded):

Press enter or click to view image in full size

Stage 1: Fingerprint every table with HASH_AGG

HASH_AGG reduces an entire table to a single 64-bit hash, computed inside Snowflake — no data movement. Row order doesn't affect the result; duplicate rows do (that matters later).

Exclude metadata columns that legitimately differ (load timestamps, batch IDs) with SELECT * EXCLUDE (...), and grab COUNT(*) for both sides in the same query — it pays off later:

WITH a AS (SELECT * EXCLUDE (_LOAD_TIMESTAMP) FROM prod_db.staging_a.orders),
b AS (SELECT * EXCLUDE (_LOAD_TIMESTAMP) FROM prod_db.staging_b.orders)
SELECT
(SELECT HASH_AGG(*) FROM a) = (SELECT HASH_AGG(*) FROM b) AS is_equal,
(SELECT COUNT(*) FROM a) AS row_count_a,
(SELECT COUNT(*) FROM b) AS row_count_b;

One comparison per query means thousands of round-trips. Packing 25 into one query with UNION ALL cuts that to a couple of hundred:

SELECT 'orders' AS table_name, (...) AS is_equal, ... , 0 AS batch_order
UNION ALL
SELECT 'customers', (...), ... , 1
-- ... 23 more ...
ORDER BY batch_order;

Finally, a ThreadPoolExecutor keeps several batches in flight at once — plain Python threads sharing one session are enough (thread-safe in recent Snowpark versions), and on an X-Small warehouse 3–4 concurrent batches is the sweet spot.

The full Stage 1 code:

import os
import logging
import concurrent.futures
import pandas as pd
from cryptography.hazmat.primitives import serialization
from snowflake.snowpark import Session
logging.basicConfig(level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(message)s")
logger = logging.getLogger(__name__)
DATABASE = "PROD_DB"
SCHEMA_A = "STAGING_A" # baseline pipeline (e.g., CSV loads)
SCHEMA_B = "STAGING_B" # candidate pipeline (e.g., Parquet loads)
EXCLUDE_COLUMNS = ["_LOAD_TIMESTAMP"] # metadata allowed to differ
# --- Connect with key-pair auth; credentials come from the environment ---
with open(os.environ["SNOWFLAKE_PRIVATE_KEY_PATH"], "rb") as f:
private_key = serialization.load_pem_private_key(f.read(), password=None)
pkb = private_key.private_bytes(
encoding=serialization.Encoding.DER,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
)
session = Session.builder.configs({
"account": os.environ["SNOWFLAKE_ACCOUNT"],
"user": os.environ["SNOWFLAKE_USER"],
"private_key": pkb,
"role": os.environ.get("SNOWFLAKE_ROLE", "VALIDATION_ROLE"),
"warehouse": os.environ.get("SNOWFLAKE_WAREHOUSE", "VALIDATION_WH"),
"database": DATABASE,
}).create()
# --- Discover tables and pair them by name ---
def list_tables(session, database, schema):
rows = session.sql(f"SHOW TABLES IN SCHEMA {database}.{schema}").collect()
return {row["name"] for row in rows}
tables_a = list_tables(session, DATABASE, SCHEMA_A)
tables_b = list_tables(session, DATABASE, SCHEMA_B)
pairs = [(f"{DATABASE}.{SCHEMA_A}.{n}", f"{DATABASE}.{SCHEMA_B}.{n}")
for n in sorted(tables_a & tables_b)]
print(f"{len(pairs)} pairs to compare | "
f"{len(tables_a - tables_b)} only in {SCHEMA_A} | "
f"{len(tables_b - tables_a)} only in {SCHEMA_B}")
# --- Batched, parallel HASH_AGG comparison ---
EXCLUDE_CLAUSE = f" EXCLUDE ({', '.join(EXCLUDE_COLUMNS)})" if EXCLUDE_COLUMNS else ""
def compare_pair_sql(table_a, table_b, order):
return f"""
SELECT
'{table_a}' AS table_a,
'{table_b}' AS table_b,
(
WITH a AS (SELECT *{EXCLUDE_CLAUSE} FROM {table_a}),
b AS (SELECT *{EXCLUDE_CLAUSE} FROM {table_b})
SELECT (SELECT HASH_AGG(*) FROM a) = (SELECT HASH_AGG(*) FROM b)
) AS is_equal,
(SELECT COUNT(*) FROM {table_a}) AS row_count_a,
(SELECT COUNT(*) FROM {table_b}) AS row_count_b,
{order} AS batch_order"""
def compare_single(session, table_a, table_b):
"""Per-pair fallback so one bad table can't sink a whole batch."""
try:
r = session.sql(compare_pair_sql(table_a, table_b, 0)).collect()[0]
return {"table_a": table_a, "table_b": table_b,
"is_equal": r["IS_EQUAL"],
"row_count_a": r["ROW_COUNT_A"],
"row_count_b": r["ROW_COUNT_B"]}
except Exception as exc:
return {"table_a": table_a, "table_b": table_b, "is_equal": False,
"row_count_a": None, "row_count_b": None, "error": str(exc)}
def compare_batch(session, batch):
query = " UNION ALL ".join(
compare_pair_sql(a, b, i) for i, (a, b) in enumerate(batch)
) + " ORDER BY batch_order"
try:
return [{"table_a": r["TABLE_A"], "table_b": r["TABLE_B"],
"is_equal": r["IS_EQUAL"],
"row_count_a": r["ROW_COUNT_A"],
"row_count_b": r["ROW_COUNT_B"]}
for r in session.sql(query).collect()]
except Exception as exc:
logger.error(f"Batch failed ({exc}); retrying tables individually")
return [compare_single(session, a, b) for a, b in batch]
def compare_all(session, pairs, batch_size=25, max_workers=3):
batches = [pairs[i:i + batch_size]
for i in range(0, len(pairs), batch_size)]
logger.info(f"Comparing {len(pairs)} pairs in {len(batches)} batches")
results, done = [], 0
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as pool:
futures = [pool.submit(compare_batch, session, b) for b in batches]
for future in concurrent.futures.as_completed(futures):
results.extend(future.result())
done += 1
if done % 10 == 0 or done == len(batches):
logger.info(f"Progress: {done}/{len(batches)} batches")
return pd.DataFrame(results)
report = compare_all(session, pairs)
report.to_csv("table_comparison_results.csv", index=False)
matches = (report["is_equal"] == True).sum()
print(f"{matches}/{len(report)} tables match "
f"({matches / len(report) * 100:.1f}%)")

Note the fallback: if any table in a batch is broken, the batch is retried one table at a time, so one bad object costs one result, not 25.

Get Zia Said’s stories in your inbox

Join Medium for free to get updates from this writer.

In a real run, roughly 90% of ~5,000 pairs were proven identical on the spot — only a few hundred moved on to Stage 2.

Stage 2: Explain the mismatches with EXCEPT / MINUS

A hash says that two tables differ, not how. For the failures, run MINUS (interchangeable with EXCEPT in Snowflake) in both directions — rows can exist only in A, only in B, or both:

def common_columns(session, table_a, table_b):
"""Align on shared columns so schema drift doesn't break the diff."""
def cols(fq_name):
db, schema, tbl = fq_name.split(".")
rows = session.sql(f"""
SELECT column_name
FROM {db}.INFORMATION_SCHEMA.COLUMNS
WHERE table_schema = '{schema}' AND table_name = '{tbl}'
""").collect()
return {r["COLUMN_NAME"] for r in rows}
return sorted((cols(table_a) & cols(table_b)) - set(EXCLUDE_COLUMNS))
mismatches = report[report["is_equal"] == False]
print(f"Diffing {len(mismatches)} mismatched pairs...")
diffs = []
for _, row in mismatches.iterrows():
a, b = row["table_a"], row["table_b"]
col_list = ", ".join(f'"{c}"' for c in common_columns(session, a, b))
a_minus_b = f"SELECT {col_list} FROM {a} MINUS SELECT {col_list} FROM {b}"
b_minus_a = f"SELECT {col_list} FROM {b} MINUS SELECT {col_list} FROM {a}"
diffs.append({
"table_a": a,
"table_b": b,
"rows_only_in_a":
session.sql(f"SELECT COUNT(*) AS c FROM ({a_minus_b})").collect()[0]["C"],
"rows_only_in_b":
session.sql(f"SELECT COUNT(*) AS c FROM ({b_minus_a})").collect()[0]["C"],
})
diff_report = pd.DataFrame(diffs)
diff_report.to_csv("table_difference_summary.csv", index=False)

The common_columns helper diffs only the columns both tables share, in a deterministic order. The output is a triage sheet: tables with a handful of differing rows get eyeballed; tables with thousands go back to the pipeline team as bugs.

The plot twist: EXCEPT can’t see duplicates

On one real migration, the Stage 2 numbers looked absurd: of well over a thousand tables that HASH_AGG flagged as different, more than 80% showed zero differing rows in both directions. HASH_AGG said “different”; EXCEPT said “identical.” Which one was lying?

Neither. The row counts told the story:

Press enter or click to view image in full size

Every “phantom mismatch” was a duplicate-row problem: one pipeline had loaded the same row twice. HASH_AGG is sensitive to duplicates, so it correctly flagged the tables. But EXCEPT/MINUS is a set operator — it de-duplicates before comparing, and {row, row} minus {row} is empty. Both tools were right; they answer different questions.

Snowflake has no EXCEPT ALL. For a duplicate-aware diff, compare counted rows instead:

SELECT c1, c2, c3, COUNT(*) AS occurrences
FROM table_a GROUP BY ALL
MINUS
SELECT c1, c2, c3, COUNT(*) AS occurrences
FROM table_b GROUP BY ALL;

A table-comparison pipeline needs three signals — HASH_AGG as the cheap "anything different?" alarm, COUNT(*) for volume and duplicates, EXCEPT for which distinct rows differ. Together they classify every mismatch: counts differ + empty EXCEPT = duplicates; counts equal + non-empty EXCEPT = changed values; both = missing or extra data.

Takeaways

  1. Fingerprint first, diff second. Spend real compute only on tables that fail the cheap test.
  2. Batch and parallelize. 25 comparisons per query and 3 threads turn thousands of round-trips into minutes.
  3. Collect row counts alongside hashes. Nearly free, and they expose duplicate problems instantly.
  4. Know your operators’ blind spots. EXCEPT silently ignores duplicate multiplicity; GROUP BY ALL + COUNT(*) closes the gap.
  5. Make validation cheap enough to repeat. At minutes and pennies per sweep, you can re-validate after every pipeline fix — and ship the migration with receipts.

All account, schema, and table names are generic placeholders; performance figures are rounded from real-world runs of this approach.

--

--