If you sell on Google from Shopify, you've probably seen a GTIN issue in Merchant Center, or a "Missing GTIN or MPN" error in Shopify's Google & YouTube channel. Merchant Center tells you which products are affected after the feed is processed. This post builds the check you can run before: one CSV row per variant whose barcode won't pass as a GTIN, with the reason.
It needs nothing but Python, requests and your store's public storefront JSON. No app, no admin API token.
What Google actually checks
From Google's GTIN help page and product data specification:
- Length: 8, 12, 13 or 14 digits (GTIN-8, UPC-A, EAN-13/JAN/ISBN-13, GTIN-14). Spaces and dashes are ignored.
- Check digit: the last digit must match the GS1 check-digit calculation.
- Not in a restricted range: Google rejects prefixes 02, 04 and 2. GS1 reserves these for restricted circulation, such as codes a store prints itself.
- Not a coupon: Google's GTIN page lists the coupon prefixes as 05, 98 and 99.
- Don't invent one. Store-brand, custom or handmade products often have no GTIN, and that's allowed. Google and Shopify both say to leave it empty and give brand and MPN instead. A made-up GTIN is worse than none.
In Shopify, the GTIN is the variant's Barcode field (Products → product → Inventory). For products without a barcode, Shopify's Google & YouTube channel lets you add brand and MPN.
The catch: barcodes aren't in /products.json
Most Shopify storefronts serve /products.json, but it leaves out variant barcodes. Each product's own /products/{handle}.json has them. So the script does two passes: page through /products.json (250 per page) to collect handles, then fetch every product's JSON. That's one request per product, so a 1,000-product store takes a while at a polite one request per second. Run it overnight or on a schedule, not in a request handler.
The script
import csv, time, requests
STORE = "https://your-store.com" # your storefront domain
UA = {"User-Agent": "gtin-fix-list/1.0"}
def gtin_problem(raw):
"""None if the barcode looks like a usable GTIN, else a short reason."""
if not (raw or "").strip():
return "empty"
d = "".join(ch for ch in raw if ch.isdigit())
if d != raw.replace(" ", "").replace("-", ""):
return "not numeric"
if len(d) not in (8, 12, 13, 14):
return f"{len(d)} digits"
body, check = [int(x) for x in d[:-1]], int(d[-1])
total = sum(n * (3 if i % 2 == 0 else 1) for i, n in enumerate(reversed(body)))
if (10 - total % 10) % 10 != check:
return "bad check digit"
if len(d) >= 12:
g13 = d.zfill(13)[-13:] if len(d) < 14 else d[1:]
if g13.startswith(("02", "04", "2")):
return "restricted range (02/04/2)"
if g13.startswith(("05", "98", "99")):
return "coupon range (05/98/99)"
return None
handles, page = [], 1
while True:
r = requests.get(f"{STORE}/products.json", params={"limit": 250, "page": page}, headers=UA, timeout=20)
r.raise_for_status()
batch = r.json()["products"]
if not batch:
break
handles += [p["handle"] for p in batch]
page += 1
time.sleep(1)
rows = []
for h in handles: # one request per product: barcodes aren't in /products.json
p = requests.get(f"{STORE}/products/{h}.json", headers=UA, timeout=20).json()["product"]
for v in p["variants"]:
problem = gtin_problem(v.get("barcode"))
if problem:
rows.append({"handle": h, "product": p["title"], "variant": v["title"],
"sku": v.get("sku") or "", "barcode": v.get("barcode") or "",
"problem": problem, "vendor": p["vendor"]})
time.sleep(1)
with open("gtin-fix-list.csv", "w", newline="") as f:
w = csv.DictWriter(f, fieldnames=list(rows[0]) if rows else ["handle"])
w.writeheader()
w.writerows(rows)
print(f"{len(handles)} products checked, {len(rows)} variants to review -> gtin-fix-list.csv")
How the GTIN check works:
- The check digit is the standard GS1 one. Working right to left (excluding the check digit), multiply digits alternately by 3 and 1, add them up, and the check digit is whatever brings the total to the next multiple of 10.
- For range checks, 12-digit UPCs are left-padded to 13 digits (a UPC starting with
2becomes02…), and 14-digit codes drop the leading indicator digit. GTIN-8 prefixes follow different rules, so the script skips range checks for them.
Reading the fix list
Sort gtin-fix-list.csv by problem, and treat each kind differently:
| problem | What it usually means | What to do |
|---|---|---|
empty |
No barcode entered. Often fine for own-brand or custom products | If the product has a manufacturer GTIN, add it. If it doesn't, add brand + MPN in the Google channel instead |
bad check digit |
A typo or a truncated code | Copy the barcode from the packaging or the supplier's data again |
N digits / not numeric
|
An SKU or internal code pasted into Barcode | Move it to SKU, and put the real GTIN (or nothing) in Barcode |
restricted range |
An in-store code, not a trade GTIN | Remove it, or replace it with the real GTIN |
coupon range |
Wrong code copied from somewhere | Find the product's real GTIN |
Two limits to keep in mind: a valid-looking GTIN can still belong to a different product (only GS1 data or the packaging can confirm it), and Merchant Center can disapprove products for reasons that have nothing to do with GTINs (price or availability mismatches, images, policy). Fix what Merchant Center's Needs attention tab reports first. Use this list to stop the GTIN issues coming back.
Make it a habit
Run it weekly and diff the CSV against last week's. New rows usually mean a new supplier import or a bulk edit went wrong, which is much easier to fix the day it happens than after Google re-reviews the feed.
The one-call version (disclosure: we built this)
We packaged a broader version of this as a pay-per-use Apify Actor: Shopify Products Scraper & AI Shopping Readiness Audit.
- Catalogue export plus audit. You get one row per product, and one audit row per store that scores GTIN check digits, descriptions, product type, vendor, image alt text, SKUs, missing images and duplicate titles. Each check gets a weight and a 0–100 score, with example handles to fix first.
-
The same GTIN checks on every variant. Turn on
readAllProductDetails, and every variant's barcode is markedvalid,missing,invalid(wrong length or check digit) orduplicate(the same code on another variant; a 12-digit UPC and the same code as a 13-digit EAN count as one). The run writes aBARCODE-ISSUESCSV with only the problem variants: in effect, the fix list above. Unlike the script, it doesn't check the restricted and coupon prefix ranges. It reads about one product per second, so give big catalogues a longer run timeout. Example input: Find Shopify variants with missing or invalid barcodes. - GTINs on a sample by default. Without that option, it checks an evenly spread sample (20 products by default, up to 250), which keeps the readiness score fast on big stores.
- Whole catalogues. It reads past Shopify's 25,000-product feed limit using the store's sitemap, collection feeds and product pages, and it says whether the catalogue was read completely. Very large catalogues (above about 20,000 products) need a run timeout longer than the default 300 seconds.
- No silent zeros. A store that isn't Shopify, is password-protected or disallows robots gets a free status row with a note.
It reads public storefront data only. It can't see your Merchant Center account, and a good score doesn't guarantee approval.
More on Merchant Center disapprovals and a quick sampled audit: Shopify product feed audit.
Prices are on the Actor's Pricing tab.
What's the most common barcode mistake you've seen in a Shopify catalogue? Tell us in the comments.
Top comments (0)