Amazon price comparison across EU countries, measured

We priced 80 products on amazon.de, .fr, .it, .es and .nl on 19 October 2026: median gap 19%, no single cheapest store. Method, tables and Python.

The same Amazon product costs a median 18.6% more in the dearest EU store than in the cheapest one. We measured it on 19 October 2026: 35 new products sold by Amazon itself, each priced on amazon.de, .fr, .it, .es and .nl, VAT included. 71% of them differed by more than 10%, and no store won consistently: Spain, France and Germany took turns at the bottom, the Netherlands was usually the dearest. The Python script that produced the numbers is at the end.

How much do Amazon prices differ between EU stores?

“Spread” here means highest price divided by lowest price, minus 1, for one product across the stores that had a price. We computed it on three nested sets of our 80 test products:

Set Products Median spread Above 10% Above 25%
Every offer with a valid euro price, 2+ stores 80 18.6% 58 (72%) 29
New, sold by Amazon, 3+ stores 35 18.6% (middle half 5.4% to 29.1%) 25 (71%) 10
New, sold by Amazon, priced in all 5 stores 19 19.4% 11 (58%) 6

Only 5 of the 35 Amazon-sold products had the same price everywhere. The widest case was a Bosch 52-piece hand tool set: 69.99 EUR in Germany, France and Spain, 96.95 in the Netherlands, 105.98 in Italy (51%).

A DEV Community study from 18 October 2026 by Atlas DataWorks reports a median of 29.3% over 86 products from amazon.de’s Best Sellers, with 73 of 86 above 10% and Germany cheapest 50 times. Our direction matches, our size is smaller, and Germany does not dominate. Their products come from the German best-seller list, ours from brands found in the search results of at least four stores, so a different gap is no surprise. Treat both as samples.

Which Amazon store is cheapest?

None, reliably. Counting only products with a single cheapest store (ties dropped), in the 35 Amazon-sold products:

Store Cheapest in Median price vs the product’s lowest Products priced
amazon.fr 8 +0.7% 35
amazon.es 7 +0.0% 23
amazon.de 6 +0.9% 34
amazon.it 4 +1.0% 28
amazon.nl 2 +14.6% 35

Spain was priced for only 23 of the 35 (see coverage below). The pattern that holds is the Netherlands, about 15% above the lowest price at the median. Example: the Sony WH-1000XM5 headphones cost 227.19 EUR in Germany, 269.99 in France, 199.00 in Italy and 239.00 in the Netherlands.

By category, devices and consoles (Kindle, Nintendo, Garmin, Samsung, 11 products) had a median spread of 1.2%. Audio (JBL, Sony headphones, 13 products) had 23.9%, home and tools (5) 23.7%, toys (6) 17.9%.

Does VAT explain the difference?

Hardly. Standard VAT rates in 2026 are 19% in Germany, 20% in France, 21% in Spain and the Netherlands, and 22% in Italy (Tax Foundation, January 2026). VAT alone can explain at most 2.5% (Germany against Italy). Dividing each price by its country’s rate moved the median spread of the 35 products only from 18.6% to 18.4%. The rest is Amazon’s own pricing per marketplace.

How did we measure it?

  1. Seeds. 16 brand queries (Logitech, Anker, Lego, Kindle, Nintendo, Philips, Samsung, Bosch, Tefal, Oral-B, JBL, Sony headphones, Playmobil, Braun, Garmin, English paperbacks) searched on each of the five stores: 80 searches, 80 credits. We kept ASINs found in the results of at least four stores and drew 80 at random, capped per query (52 seen in all five stores, 28 in four).
  2. Prices. One product lookup per ASIN per store: 400 lookups at 1 credit each, with country and postal code set per store.
  3. Retries. 87 lookups had no price on the first pass. A second pass recovered 50 of them (below).
  4. Validation. We kept a price only if it was in euros and the displayed string agreed with extracted_price to the cent. Of 400 lookups, none disagreed. We also read the titles of the eight widest spreads in the Amazon-sold set: same model in every language.

Usable price per store, out of 80 products after the retry: Germany 78, France 79, Netherlands 77, Italy 67, Spain 62. Missing means the call failed (422 target_unavailable, not charged) or the page had no buy box price. The whole run used about 510 credits on 19 October 2026; successful product lookups took a median 9.2 seconds, p90 30 seconds.

What goes wrong when you compare marketplaces?

  • Locale parameters matter. For one ASIN, amazon.fr, .it, .es and .co.uk called without country and postal_code returned a page with no price (location “United States”). With them, we got prices. amazon.nl is the exception: it answered 400 to any postal_code, so send only country=nl. The delivery city can differ from the postal code you ask for (Milan 20121 came back as “Figino 20153”), so read prices as “a typical address in that country”.
  • Price strings have four shapes: €69.99 with 36 percent savings, 9,99 €, € 10,99 and 135,24 € con 32 un risparmio percentuale. Parse the first number, decide the decimal mark from the last separator, and refuse anything without the euro sign. A German page once returned extracted_average_rating 47 for “4,7”, so cross-check parsed numbers.
  • amazon.co.uk is GBP. It works (country gb plus a postal code returned a pound price), but we left it out to avoid exchange-rate arguments.
  • “Sold by” changes the answer. Warehouse and third-party offers (“Amazon Seconde main”, resellers on one store) produced our biggest outliers, so the headline sets keep Amazon-sold new offers only.
  • Failures are transient. 31 of 80 Italian and 32 of 80 Spanish first-pass lookups failed with 422; a retry recovered 20 and 17. Retry, then treat the rest as “not offered”.

How do I run this comparison in Python?

The script takes ASINs, fetches each on five stores, keeps only validated euro prices of Amazon-sold offers, retries 422/429/5xx, and stops at a credit cap. It uses the Amazon endpoint (resource=product, 1 credit).

import os, re, statistics, sys, threading, time
from concurrent.futures import ThreadPoolExecutor

import requests

API = "https://api.fastaf.co/v1/amazon"
HEADERS = {"Authorization": f"Bearer {os.environ['FASTAF_API_KEY']}"}
CREDIT_CAP = int(os.environ.get("CREDIT_CAP", 200))
WORKERS = int(os.environ.get("WORKERS", 5))
STORES = {  # amazon.nl rejects postal_code, so there only the country is sent
    "de": {"domain": "de", "country": "de", "postal_code": "10115"},
    "fr": {"domain": "fr", "country": "fr", "postal_code": "75001"},
    "it": {"domain": "it", "country": "it", "postal_code": "20121"},
    "es": {"domain": "es", "country": "es", "postal_code": "28013"},
    "nl": {"domain": "nl", "country": "nl"},
}
spent, lock = 0, threading.Lock()


def fetch(asin, store):
    global spent
    params = {"resource": "product", "asin": asin, **STORES[store]}
    for attempt in range(4):
        with lock:
            if spent >= CREDIT_CAP:
                return None  # stop before the cap, not after
        r = requests.get(API, params=params, headers=HEADERS, timeout=120)
        with lock:
            spent += int(r.headers.get("X-FastAF-Credits-Used", 0))
        if r.status_code == 200:
            return r.json()
        if r.status_code in (422, 429, 500, 502, 503, 504):  # failed calls are free
            time.sleep(3 * (attempt + 1))
            continue
        return None
    return None


def parse_eur(text):
    """'€69.99 with 36 percent savings', '9,99 €', '€ 1.299,00' -> float."""
    if not text or "€" not in text:
        return None  # refuse anything that is not euros
    m = re.search(r"\d[\d.,\s  ]*\d|\d", text)
    s = re.sub(r"[\s  ]", "", m.group())
    cut = max(s.rfind(","), s.rfind("."))
    if cut != -1 and len(s) - cut - 1 in (1, 2):  # last separator = decimal mark
        return float(re.sub(r"[.,]", "", s[:cut]) + "." + s[cut + 1:])
    return float(re.sub(r"[.,]", "", s))


def price(page):
    if not page or not page.get("price"):
        return None
    shown, extracted = parse_eur(page["price"]), page.get("extracted_price")
    if shown is None or extracted is None or abs(shown - extracted) > 0.011:
        return None  # the two parses disagree: drop it rather than guess
    sold_by = page.get("sold_by") or ""
    return shown if sold_by.lower().startswith("amazon") else None  # Amazon-sold only


asins = sys.argv[1:]
jobs = [(a, s) for a in asins for s in STORES]
with ThreadPoolExecutor(WORKERS) as pool:
    prices = dict(zip(jobs, pool.map(lambda j: price(fetch(*j)), jobs)))

spreads, wins = [], dict.fromkeys(STORES, 0)
print("asin        " + "".join(f"{s:>9}" for s in STORES) + "   spread")
for a in asins:
    row = {s: prices[(a, s)] for s in STORES if prices[(a, s)]}
    spread = max(row.values()) / min(row.values()) - 1 if len(row) > 1 else None
    if spread is not None:
        spreads.append(spread)
        low = [s for s, p in row.items() if p == min(row.values())]
        if len(low) == 1:
            wins[low[0]] += 1
    cells = "".join(f"{row[s]:9.2f}" if s in row else f"{'-':>9}" for s in STORES)
    print(f"{a}  {cells}   " + (f"{spread:6.1%}" if spread is not None else "   n/a"))
if spreads:
    print(f"\nmedian spread {statistics.median(spreads):.1%} over {len(spreads)} products")
    print("cheapest store:", wins)
print(f"credits used: {spent} (cap {CREDIT_CAP})")

Run against production on 19 October 2026 with python price_compare.py B0DXKNBQS6 B0C3CRCMKJ (a JBL speaker and the Bosch tool set):

asin               de       fr       it       es       nl   spread
B0DXKNBQS6     127.99   140.82   135.24        -   150.00    17.2%
B0C3CRCMKJ      69.99    69.99   105.98    69.99    96.95    51.4%

median spread 34.3% over 2 products
cheapest store: {'de': 1, 'fr': 0, 'it': 0, 'es': 0, 'nl': 0}
credits used: 9 (cap 12)

The dash is real: the amazon.es lookup of the JBL returned no usable price in that run. Two products across five stores cost 9 credits, so a 30-product watchlist is about 150 credits per check, roughly 8 US cents on a Starter pack. For a recurring job with alerts see the price tracking bot; for the product fields, scraping Amazon product data; coming from the retired PA-API, this migration guide.

What are the limits of this data?

  • One day. Everything was fetched on 19 October 2026. Prices and deals move daily; a different week would give a different table.
  • Buy box price, shipping excluded. Delivery cost, coupons and Prime-only deals can change the real price. “With 36 percent savings” prices include the current deal.
  • The sample favours widely sold brands. It says nothing about niche or local-only products, and an ASIN is not always the same product everywhere, which is why we checked titles.
  • Small sample. 35 products in the headline set; the middle half runs from 5% to 29%, so read the median as “around 19%”.

The short version

Same ASIN, five EU Amazon stores, one day: a median gap of 18.6% between cheapest and dearest, above 10% for 71% of Amazon-sold products, almost none of it VAT. The Netherlands was dearest at the median; the cheapest store changed from product to product. Send country and postal_code per store (not for amazon.nl), validate every parsed price, retry the 422s, and cap your credits. Get 500 free credits and run the script, or read the docs first.