Deduplicating records with content hashing #
The same entity reaches your pipeline through many doors: two URLs that render identical product data, a re-crawl that returns unchanged content, a paginated feed that overlaps the previous page. Storing all of them wastes space and skews analytics. Content hashing gives each record a compact fingerprint derived from its meaningful fields, so “have I already stored this exact content?” becomes a single index lookup. This page explains how to canonicalise a record, compute a stable SHA-256 over its normalised fields, store the digest, and skip on seen. It is a detail page under Deduplication Strategies for Scraped Data within the Pipeline Storage, Deduplication & Monitoring section.
Problem Framing #
There are two distinct dedup questions and content hashing answers one of them precisely. Entity deduplication asks “are these two records the same thing?” — that is a natural-key or fuzzy-matching problem. Content deduplication asks “is this record byte-for-byte the same content I already have?” — that is a hashing problem. Content hashing is exact, cheap, and unambiguous: identical meaningful fields produce an identical digest, and any change produces a completely different one.
The trap is stability. A hash is only useful if the same logical record always produces the same digest. Serialise a Python dict without sorting keys and the hash flips between runs because dict ordering, whitespace, and float formatting all vary. Include a scraped_at timestamp in the hash and every re-crawl looks “new”. The entire technique lives or dies on a disciplined canonicalisation step that strips volatile fields and produces one deterministic byte string per logical record.
Step-by-Step Implementation #
- Select the semantic fields. Decide which fields define the content. Exclude volatile metadata — fetch timestamp, crawl id, proxy IP, response latency — that changes without the content changing.
- Normalise each field. Trim and collapse whitespace, lowercase where case is not significant, normalise Unicode to NFC, and format numbers and dates to a fixed representation. This is what makes
" 9.90 "and"9.9"hash the same when they mean the same price. - Serialise deterministically. Dump to JSON with
sort_keys=True, no insignificant whitespace, andensure_ascii=False, then encode as UTF-8. Sorted keys remove dict-ordering nondeterminism. - Hash with SHA-256 over the canonical bytes to get a stable hex digest.
- Store and skip on seen. Keep the digest in a unique index (or a Redis set for hot lookups) and drop records whose hash already exists.
import hashlib
import json
import unicodedata
# Fields that must NOT enter the hash: they change without the content changing.
VOLATILE = {"scraped_at", "crawl_id", "proxy_ip", "response_ms", "etag"}
def _normalise_scalar(value):
if isinstance(value, str):
# NFC-normalise, strip, and collapse internal whitespace runs.
text = unicodedata.normalize("NFC", value).strip()
return " ".join(text.split())
if isinstance(value, float):
# Fixed formatting so 9.9 and 9.90 do not diverge.
return format(value, ".4f")
return value
def canonicalise(record: dict) -> bytes:
"""Produce one deterministic byte string per logical record."""
cleaned = {
k: _normalise_scalar(v)
for k, v in record.items()
if k not in VOLATILE and v is not None
}
return json.dumps(
cleaned, sort_keys=True, separators=(",", ":"), ensure_ascii=False
).encode("utf-8")
def content_hash(record: dict) -> str:
"""Stable SHA-256 hex digest over the record's normalised fields."""
return hashlib.sha256(canonicalise(record)).hexdigest()
Wire the skip-on-seen check into the write path. A database unique index on the digest is the durable source of truth; a Redis set in front of it keeps the common “already seen” case off the database:
def store_if_new(conn, redis, record: dict) -> bool:
digest = content_hash(record)
# Fast path: SADD returns 0 if the member already existed.
if redis.sadd("seen:content", digest) == 0:
return False # duplicate — skip silently
with conn.cursor() as cur:
# Durable guard: the unique index is authoritative if Redis is cold.
cur.execute(
"INSERT INTO record (content_hash, payload) VALUES (%s, %s) "
"ON CONFLICT (content_hash) DO NOTHING RETURNING id",
(digest, json.dumps(record)),
)
inserted = cur.fetchone() is not None
conn.commit()
return inserted
The Redis SADD is an optimisation, not the guarantee — the ON CONFLICT (content_hash) DO NOTHING on a UNIQUE index is what keeps the store correct even if the cache is flushed or a second worker races. This same digest feeds the conditional updates described in upserting scraped records into Postgres, so compute it once and pass it downstream.
Verification & Testing #
Assert the two properties that make the hash trustworthy — determinism and volatility-insensitivity:
def test_hash_is_stable_and_ignores_volatile_fields():
a = {"sku": "A1", "title": "Widget ", "price": 9.9,
"scraped_at": "2026-07-05T10:00:00Z", "proxy_ip": "10.0.0.1"}
b = {"price": 9.90, "sku": "A1", "title": "Widget", # reordered + reformatted
"scraped_at": "2026-07-05T11:30:00Z", "proxy_ip": "10.0.0.9"}
# Same content, different volatile metadata and key order → same digest.
assert content_hash(a) == content_hash(b)
c = {**a, "price": 10.0} # real change
assert content_hash(a) != content_hash(c)
Confirm the collision surface is negligible with a git-style sanity check: SHA-256 over normalised fields makes an accidental collision astronomically unlikely, so any two records sharing a digest can be treated as identical content. Measure the dedup rate in production by comparing SADD hits to total records — a sudden drop toward zero usually means a volatile field leaked into canonicalise.
Compliance & Operational Guardrails #
- A hash of personal data is still personal data. A SHA-256 digest over a name and address is a pseudonym, not anonymisation — it is reversible by anyone who can re-hash a guess. Keep hashed personal fields inside the same retention and access controls as the raw record, per GDPR compliance for scraped personal data.
- Version your canonicalisation logic. If you change which fields or normalisation rules feed the hash, store a
hash_versionalongside the digest so old and new digests are never compared as equals. - Make dedup auditable. Log the digest and the skip decision so an audit can show why a record was or was not stored, without retaining the duplicate payload itself.
Common Mistakes #
- Hashing the raw serialised dict. Unsorted keys, stray whitespace, and float formatting make the digest unstable across runs, so nothing ever matches. Canonicalise first.
- Including volatile fields. A
scraped_atorproxy_ipinside the hash makes every re-crawl look new, defeating the entire purpose. - Trusting the cache as the source of truth. A Redis set alone loses all history on a flush; back it with a
UNIQUEindex andON CONFLICT DO NOTHING.
Frequently Asked Questions #
Why SHA-256 rather than a faster hash like MD5 or a CRC? #
CRCs and short hashes have high collision rates at scale — with millions of records you will get accidental collisions that silently drop distinct content. SHA-256’s 256-bit space makes accidental collisions effectively impossible, and the CPU cost is trivial next to network and parsing time. Non-cryptographic hashes such as xxHash are acceptable only for a first-pass filter that is confirmed by an exact comparison.
How do I dedup near-duplicates that differ by a word or a timestamp in the body? #
Content hashing is exact by design and will not catch near-duplicates — that is a feature, because it never merges records that genuinely differ. For fuzzy matching (say, the same article republished with minor edits), use a similarity technique such as MinHash or SimHash as a separate stage; keep exact content hashing as the fast, unambiguous first line of defence.
Related guides #
- Deduplication Strategies for Scraped Data — the parent technique comparing exact, fuzzy, and key-based dedup.
- Upserting scraped records into Postgres — use the content hash to drive conditional, idempotent writes.
- Deduplicating URLs with a Redis seen-set — the fetch-time analogue that stops you re-downloading the same URL.