The white LACK side table, article 30449908, costs €9.99 in Germany, France, Spain, Austria and Belgium. In Portugal the same article number costs €12.99. Same product, same currency, a 30% gap, and no exchange rate to blame.
I wanted to know whether that was a quirk of one cheap table or a pattern, so I built a small IKEA price index in Python: the same article numbers, priced in nine euro-zone countries on the same day. It is a pattern. Not one of the 72 articles I could match had the same price in all nine countries, and on average the French site charges 37% more than the Dutch one for identical items.
The data
I used the IKEA Pro API, which returns IKEA's catalogue as JSON for a given countryCode. Everything here comes from one endpoint, /product-search-by-keyword: five IKEA families (BILLY, KALLAX, POÄNG, MALM, LACK) searched in nine euro-zone countries (Germany, France, Italy, Spain, the Netherlands, Austria, Belgium, Ireland, Portugal), first page of results only, all collected on 24 October 2026. That is 45 requests.
Each result carries an id (IKEA's article number), name, typeName, details.designText (the colour or finish), price.currentPrice, and a variants list where every colour variant has its own id and price. Counting the variants, those 45 searches returned 1,220 distinct article numbers. Only 72 of them showed up in all nine countries, and those 72 are the basket.
Sticking to the euro zone was deliberate. Once you compare euros with kronor or dollars, half of any gap you find is the exchange rate on the day you picked. Within the euro zone, a price difference is a price difference.
The code
You need a RapidAPI key (the API's RapidAPI page has a free tier) in the RAPIDAPI_KEY environment variable, plus requests and pandas. The script caches every response on disk, so it spends 45 calls once and nothing after that.
import json
import os
from pathlib import Path
import pandas as pd
import requests
HOST = "ikea-api-pro.p.rapidapi.com"
CACHE = Path("ikea_cache")
COUNTRIES = {"de": "en", "fr": "fr", "it": "it", "es": "en", "nl": "en", # code: language
"at": "de", "be": "en", "ie": "en", "pt": "en"} # language changes text, not prices
KEYWORDS = ["billy", "kallax", "poang", "malm", "lack"]
def search(keyword, country):
"""Page 1 of a keyword search, cached on disk so a re-run costs no calls."""
file = CACHE / f"{country}-{keyword}.json"
if file.exists():
return json.loads(file.read_text())
r = requests.get(
f"https://{HOST}/product-search-by-keyword",
params={"keyword": keyword, "countryCode": country,
"languageCode": COUNTRIES[country]},
headers={"x-rapidapi-host": HOST,
"x-rapidapi-key": os.environ["RAPIDAPI_KEY"]},
timeout=60,
)
r.raise_for_status()
CACHE.mkdir(exist_ok=True)
file.write_text(r.text)
return r.json()
rows = []
for keyword in KEYWORDS:
for country in COUNTRIES:
for p in search(keyword, country)["data"]["products"]:
# The listed product plus its colour variants, each with its own article number
items = [(p["id"], p["details"]["designText"], p["price"])]
items += [(v["id"], v["designText"], v["price"]) for v in p.get("variants") or []]
for article, colour, price in items:
rows.append({"country": country, "id": article, "name": p["name"],
"type": p["typeName"], "colour": colour,
"price": price["currentPrice"]})
df = pd.DataFrame(rows).drop_duplicates(["country", "id"])
wide = df.pivot_table(index="id", columns="country", values="price", aggfunc="first")
basket = wide.dropna() # article numbers priced in all nine countries
rel = basket.div(basket.median(axis=1), axis=0) # 1.0 = that article's median price
index = (rel.mean() * 100).round(1).sort_values()
print(f"{len(wide)} article numbers seen, {len(basket)} priced in all 9 countries")
print(index.to_string())
print("Cheapest (or tied) for this many articles:")
print(basket.eq(basket.min(axis=1), axis=0).sum().sort_values(ascending=False).to_string())
Two decisions in there matter more than the rest. The join key is the article number, never the name, for reasons the pitfalls section makes painfully clear. And the index treats every article equally: each price is divided by that article's median across the nine countries, then averaged per country, so a €179 shelving unit does not drown out a €4.99 wall shelf. An index of 100 means "typical euro-zone price".
The first lines it prints:
1220 article numbers seen, 72 priced in all 9 countries
country
nl 84.2
de 91.6
pt 97.2
France is the dearest IKEA in the euro zone, the Netherlands the cheapest
The Netherlands comes out at 84.2 and France at 115.2. Germany is the second cheapest at 91.6, and the other six countries sit within about five points of 100. Buying one of each of the 72 articles would cost €2,779.89 on the Dutch site and €3,835.46 on the French one, 38% more.
The ranking is not driven by a few outliers. The Netherlands was the cheapest country, or tied for cheapest, on 51 of the 72 articles and the dearest on none. France was the dearest, or tied, on 34. Ireland was never the cheapest country for a single article.
| Country | Index | Index without VAT | One of each (72) | Cheapest or tied | Dearest or tied |
|---|---|---|---|---|---|
| Netherlands | 84.2 | 84.5 | €2,779.89 | 51 | 0 |
| Germany | 91.6 | 93.4 | €2,945.44 | 29 | 5 |
| Portugal | 97.2 | 95.8 | €3,255.74 | 12 | 8 |
| Italy | 102.3 | 101.7 | €3,341.20 | 2 | 6 |
| Spain | 103.6 | 103.9 | €3,325.39 | 8 | 13 |
| Belgium | 104.0 | 104.3 | €3,443.39 | 5 | 6 |
| Ireland | 104.2 | 102.8 | €3,334.00 | 0 | 10 |
| Austria | 105.3 | 106.5 | €3,542.43 | 3 | 14 |
| France | 115.2 | 116.6 | €3,835.46 | 1 | 34 |
The family matters too. France's premium is mostly KALLAX: on the 34 KALLAX articles in the basket its index is 125.1, against 105.3 on LACK. LACK is where Spain looks expensive (111.9) and Germany cheap (84.7). The Netherlands is the cheapest country on BILLY and KALLAX; on LACK, Germany edges it, 84.7 to 85.6.
Almost nothing has one euro-zone price
For each article I compared the dearest country with the cheapest. The median gap is 50.7%. Only 2 of the 72 articles stay within 25%, 43 differ by more than 50%, and 13 by 100% or more. The widest is a KALLAX insert with door: €7.50 in Portugal, €20 in France.
The LACK side table from the opening is a good example of how small these numbers can look while still being large in percentage terms: €8.99 in the Netherlands, €9.95 in Italy, €9.99 in five countries, €12 in Ireland and €12.99 in Portugal.
VAT does not explain it
The obvious objection is tax. EU shelf prices for consumers include VAT (the price indication directive defines the selling price as the final price including VAT), and the standard rates in these nine countries run from 19% in Germany to 23% in Ireland and Portugal (standard rates on the EU's Your Europe site).
So I divided every price by one plus its country's standard rate and rebuilt the index. Nothing important moved. France goes from 115.2 to 116.6 and the Netherlands from 84.2 to 84.5. The largest shift is Germany's, from 91.6 to 93.4, because it has the lowest VAT of the nine. A spread of 19% to 23% in tax cannot produce a 37% gap in prices, and France's 20% rate is in the middle of the range anyway.
What tripped me up in the data
The most famous bookcase is not in the basket. You would expect the white BILLY to be the anchor of any IKEA price index. It never made the 72, because Europe sells it under two sets of article numbers. Germany, the Netherlands, Austria, Belgium and Ireland list 00263850 (from €42.49 in the Netherlands to €65 in Ireland) and its siblings. France, Italy, Spain and Portugal list 50522040 (€44.99 to €49.99) and three siblings, the same four numbers the US site lists. This is not page-one noise: searching the exact article number 00263850 returns one result in Germany and zero in France, and 50522040 returns zero in Germany. The search response has no dimensions field, so I cannot tell from the data whether the two white bookcases are identical apart from the number; you can compare the German and French product pages yourself. Either way, joining on the number quietly drops BILLY white, and joining on the name compares two different articles.
Names are not keys. In Germany, page one of the KALLAX search has eight different article numbers that are all "KALLAX, Shelving unit, white", priced from €16.99 to €129. They are different sizes. Across the nine countries there were 101 such groups where name, type and colour were identical but the article numbers were not. Anything that matches on text will happily compare a small shelf with a large one.
Combinations have their own numbers, and they are local. IDs starting with s are combinations: a POÄNG armchair is a frame plus a cushion sold as one number. Of the 1,220 numbers I saw, 724 were combinations, and only 5 survived into the basket. The same POÄNG armchair and footstool in walnut effect with a Kelinge beige cushion is s29617733 in Germany, the Netherlands and Portugal (€149, €168 and €170) and s59617736 in Ireland (€199). POÄNG contributed nothing to the index for that reason.
Search is not a name filter. Page one of the "malm" search in Germany had 22 results: 5 named MALM and 10 named STORKLINTA. In Belgium only 2 of 22 were MALM. The total result count for the same keyword ranged from 75 in France to 615 in Germany. MALM contributed nothing either. If you want a specific article, search its number.
currentPrice is all you get, so promotions look like prices. The search response has a current price and no regular price. Seven of the nine Dutch BILLY prices in the basket end in .49 (€25.49, €42.49), and €42.49 is exactly 85% of €49.99, which looks like a 15% markdown. Portugal lists KALLAX door inserts at €7.50 where five other countries charge €15. I cannot prove either is a promotion from these fields, so I re-ran the index without BILLY and without the KALLAX inserts, 54 articles in all. The ends held: the Netherlands at 85.2, Germany 88.4, France 113.7. Austria moved up to 110.0.
formattedPrice is localised. Spain returns "44,99", Germany "59.99", the US "$79.00". Parse currentPrice, which is always a number. Badges are localised as well: the same idea arrives as "Top seller", "Best seller", "Bestseller", "Top ventes" and "Il più venduto", so filtering on one string misses most of them.
One page is a sample, not a catalogue. 615 of the 1,220 article numbers appeared in just one country. Most of that is ranking: I read page one of each search, and each country orders its results differently. Absence from page one does not mean IKEA does not sell it there, which is why I checked the white BILLY with exact-number searches before writing that paragraph.
What I would do next
Three things. Paginate with the page parameter, so the basket covers whole families instead of whatever ranked on page one. Run the same basket weekly, which is the only honest way to tell a 15% promotion from a list price. And add Sweden, Switzerland, the UK and the US with ECB reference rates for a stated date, accepting that the gaps then include currency.
The data came from the IKEA Pro API. If you want a full catalogue as a file instead, there is an IKEA US product dataset, and for anything custom, get in touch.



Top comments (0)