If two listings carry the same valid barcode, they are the same product, and you are done in one comparison. Nothing else in product matching is that cheap or that certain, so the first thing to build is the identifier path — even though it will only cover part of your catalogue.
The confusion worth clearing up first is that these are not competing standards. They are mostly the same number written at different lengths.
The identifiers you will meet
| Identifier | What it is | Length | Reliability for matching |
|---|---|---|---|
| GTIN | The umbrella term — GTIN-8, 12, 13 and 14 are all GTINs | 8 to 14 digits | Highest, when valid |
| EAN | European article number, the common retail barcode | 13 digits | Highest, when valid |
| UPC | North American equivalent; a GTIN-13 with a leading zero | 12 digits | Highest, when valid |
| MPN | Manufacturer part number — the maker's own code | Free-form | High with the brand, meaningless without it |
| ASIN | Amazon's internal identifier | 10 characters | Only within Amazon |
| Retailer SKU | The shop's own code | Free-form | None across retailers |
Validate before you trust
Every GTIN carries a check digit, computed from the preceding digits. It exists so a scanner can tell a misread from a real code, and it works just as well for telling a scraped mistake from a real code.
Validating costs microseconds and removes a whole class of silent corruption: a truncated field, a number that was actually a model code, a cell that picked up a stray character. Any identifier that fails the check digit should be discarded rather than used, because a wrong identifier is more dangerous than no identifier — it will match confidently to something arbitrary.
Check digit validation
The standard GS1 modulo-10 calculation, normalised for any GTIN length.
def valid_gtin(code: str) -> bool:
"""True if code is a syntactically valid GTIN-8/12/13/14."""
digits = "".join(c for c in code if c.isdigit())
if len(digits) not in (8, 12, 13, 14):
return False
body, check = digits[:-1], int(digits[-1])
# Weights alternate 3,1 from the rightmost body digit leftwards.
total = sum(int(d) * (3 if i % 2 == 0 else 1)
for i, d in enumerate(reversed(body)))
return (10 - total % 10) % 10 == check
def normalise(code: str) -> str | None:
"""Pad to 14 digits so UPC-12 and EAN-13 compare equal."""
if not valid_gtin(code):
return None
return "".join(c for c in code if c.isdigit()).zfill(14)Normalising to 14 digits is what makes a 12-digit UPC and the same product's 13-digit EAN compare as equal.
Where retailers actually publish them
In descending order of how often it works: the JSON-LD Product block, where gtin13, gtin, sku and mpn are standard fields; a microdata itemprop; a specifications table on the page; and the product feed, if the retailer publishes one.
Expect patchy coverage and expect it to vary wildly by sector. Groceries and electronics are good. Fashion and furniture are poor, often because the item genuinely has no manufacturer barcode. That is not a gap you can engineer away, which is what lesson three exists for.
One practical note if you are pulling these out of structured data: an array in a JSON-LD path needs to be addressed as its own segment. A product with variants will put the barcodes under something like hasVariant with each entry carrying its own gtin13, and a path that does not account for the array returns the first one for everything.
Three ways a valid identifier still produces a wrong match
The check digit proves the number is well-formed. It does not prove it is the right number for this listing.
- Reused codes. Small manufacturers reuse barcodes across generations. The same EAN can be last year's model at one retailer and this year's at another.
- Bundle codes. A retailer sells a drill plus a case under the bare drill's barcode because that is what their system had. The identifier is valid, the products are not equivalent.
- Copy-paste errors in the retailer's own catalogue. A listing carries the barcode of a neighbouring variant. Rare individually, guaranteed across a hundred thousand rows.
Worked example: four codes, two products
Four identifiers exactly as four retailers publish them, covering two distinct products. All four are valid, all four are the same kind of number, and a plain string comparison matches none of them to anything at all. Left-padding everything to fourteen digits makes A equal B and C equal D — and that single step is what stops US and European catalogues failing to match in complete silence.
| Source | Published as | Form | Normalised to GTIN-14 |
|---|---|---|---|
| A, a European retailer | 4006381333931 | EAN-13 | 04006381333931 |
| B, another European retailer | 04006381333931 | GTIN-14, already padded | 04006381333931 |
| C, a US retailer | 036000291452 | UPC-A | 00036000291452 |
| D, the same US product elsewhere | 0036000291452 | the EAN-13 form of that UPC | 00036000291452 |
| A plain string comparison | matches none of the four to each other |
What usually goes wrong
Identifiers are the one tier of evidence that can be certain, which is exactly why the failures here are so damaging.
- Comparing identifiers as strings without padding. A 12-digit UPC and the 13-digit EAN of the same item differ by one leading zero and nothing else, and the match simply never happens.
- Trusting an identifier because it is the right length. Validate the check digit: a transposed pair of digits produces a number that looks perfect and refers either to something else or to nothing.
- Treating an MPN as globally unique. It is unique within a manufacturer, so it needs the brand beside it to mean anything, and two brands both using "100" is entirely ordinary.
- Assuming coverage. Fashion and furniture frequently publish no barcode on the page at all, so an identifier-first strategy returns almost nothing there — a scoping fact rather than a bug.
- Accepting an identifier match with no sanity check. Reused codes, bundle codes and plain catalogue errors all pass the check digit; comparing title overlap and the price ratio catches all three for almost no effort.
- Taking the first identifier on the page. Related-product carousels and accessory blocks publish codes of their own, and the one you want is inside the main product block.
The cheap guard that catches all three
After an identifier match, compare the two titles and the two prices. If the titles share almost no words, or the prices differ by more than roughly a factor of two, flag it rather than publishing it.
This is a handful of lines and it catches every one of the failure modes above, because all three produce a match that is confident and visibly implausible. An identifier match that disagrees with every other signal is not a strong match — it is a strong contradiction, and that is worth a human's attention rather than an auto-publish.
Next: the majority of your catalogue, which has no usable barcode at all.
Lesson 3: when there is no barcode