Israel's price level against the OECD — checking the essay's arithmetic¶
This notebook computes every number in the essay Israel is a rich country that is too expensive to live in, straight from the source the essay cites, and then tries to break the conclusion.
The claim. Israel ranks 11th of the 38 OECD countries by output per person at market exchange rates and 26th once you adjust for what the money buys. The ratio of those two numbers is a price level, Israel's is 118 against the United States at 100, and that is far higher than Israel's own productivity predicts.
The data. IMF World Economic Outlook, April 2026 vintage, pulled live from the IMF's DataMapper API — no key, no scraping, a documented public endpoint. Two indicators do all the work:
| Indicator | Meaning |
|---|---|
NGDPDPC |
GDP per capita, current prices, U.S. dollars — converted at the market exchange rate |
PPPPC |
GDP per capita, current prices, international dollars — converted at purchasing power parity |
Their ratio is the comparative price level. Nothing else is needed for the headline.
The cross-check. A second, genuinely independent estimate from the World Bank: the
ICP-based PPP conversion factor (PA.NUS.PPP) over the market exchange rate
(PA.NUS.FCRF). Different institution, different price survey, different vintage. If the two
disagree, the essay should say so — and it does, so this notebook checks whether the number
it quotes is right.
What this notebook adds to the essay. Three things the essay could not do in prose:
- It asserts every published figure rather than restating it. If the IMF revises, the assertions fail loudly instead of the essay quietly going stale.
- It runs the regression under eight specifications, not one, and reports where Israel lands in each.
- It puts the 2026 figure in its own historical context, which turns out to matter more than the essay lets on.
import json
import ssl
import urllib.request
from pathlib import Path
import numpy as np
import pandas as pd
import matplotlib as mpl
import matplotlib.pyplot as plt
pd.set_option("display.width", 200)
pd.set_option("display.max_rows", 60)
HERE = Path.cwd()
CACHE = HERE / "data"
OUT_JS = HERE.parent / "assets" / "data.js"
YEAR = "2026" # the vintage the essay is written against
# --- the site's palette, so the notebook and the published charts agree -------
PAPER = "#FFFBF0"
SAND = "#C0AE86" # every other country: context, not an identity
MARK = "#1E4A85"
BRICK = "#BE3C2C" # Israel, the one highlighted series
AMBER = "#C0810C"
MARI = "#F2C230"
INK = "#12305C"
MUTED = "#5C6779"
mpl.rcParams.update({
"figure.dpi": 130, "savefig.dpi": 130,
"font.family": "sans-serif", "font.style": "normal",
# Not "Roboto" on purpose -- see analysis/README.md. An italic-only local
# install makes matplotlib render every figure in italics.
"font.sans-serif": ["Helvetica Neue", "Helvetica", "Arial", "DejaVu Sans"],
"font.size": 9,
"axes.edgecolor": "#E5DAC0", "axes.labelcolor": MUTED, "axes.titlesize": 11,
"axes.titleweight": "bold", "axes.titlecolor": INK, "axes.titlelocation": "left",
"axes.spines.top": False, "axes.spines.right": False,
"xtick.color": MUTED, "ytick.color": MUTED,
"xtick.labelcolor": MUTED, "ytick.labelcolor": MUTED, "text.color": INK,
"figure.facecolor": PAPER, "axes.facecolor": PAPER, "savefig.facecolor": PAPER,
"legend.frameon": False, "legend.fontsize": 8.5,
})
def fetch(url, timeout=90):
# python.org macOS builds often ship without a usable CA bundle, which
# turns every https call into CERTIFICATE_VERIFY_FAILED. Use certifi's
# bundle when it is installed and fall back to the system default.
try:
import certifi
ctx = ssl.create_default_context(cafile=certifi.where())
except ImportError:
ctx = None
with urllib.request.urlopen(url, timeout=timeout, context=ctx) as r:
return r.read()
print("cache:", CACHE, "|", "exists" if CACHE.is_dir() else "will be created")
cache: /Users/davidzeff/Desktop/blog/analysis/data | exists
1. The comparison group¶
"OECD" has to mean something exact or the rankings are meaningless — a rank of 11th is a statement about who else is in the list. These are the 38 member countries as of 2026, which is the set the essay ranks against. Costa Rica (2021) is the most recent accession.
OECD38 = {
"AUS": "Australia", "AUT": "Austria", "BEL": "Belgium", "CAN": "Canada",
"CHL": "Chile", "COL": "Colombia", "CRI": "Costa Rica", "CZE": "Czechia",
"DNK": "Denmark", "EST": "Estonia", "FIN": "Finland", "FRA": "France",
"DEU": "Germany", "GRC": "Greece", "HUN": "Hungary", "ISL": "Iceland",
"IRL": "Ireland", "ISR": "Israel", "ITA": "Italy", "JPN": "Japan",
"KOR": "Korea", "LVA": "Latvia", "LTU": "Lithuania", "LUX": "Luxembourg",
"MEX": "Mexico", "NLD": "Netherlands","NZL": "New Zealand", "NOR": "Norway",
"POL": "Poland", "PRT": "Portugal", "SVK": "Slovakia", "SVN": "Slovenia",
"ESP": "Spain", "SWE": "Sweden", "CHE": "Switzerland", "TUR": "Türkiye",
"GBR": "United Kingdom", "USA": "United States",
}
assert len(OECD38) == 38, len(OECD38)
print(f"{len(OECD38)} member countries")
38 member countries
2. Pull the IMF series¶
The DataMapper endpoint returns one indicator for every economy and every year in one
response, so two requests cover the whole essay. They are cached to data/ on first run: the
notebook should give the same answer next month, and a live API is not a citation.
To deliberately re-pull a fresh vintage, delete data/imf-weo-*.json and re-run.
API = "https://www.imf.org/external/datamapper/api/v1/{}"
def imf(indicator):
# -> {iso3: {year: value}}, cached on disk so the notebook is reproducible
CACHE.mkdir(exist_ok=True)
path = CACHE / f"imf-weo-{indicator}.json"
if not path.exists():
print(f"downloading {indicator} …")
path.write_bytes(fetch(API.format(indicator)))
else:
print(f"{indicator}: cached")
return json.loads(path.read_text())["values"][indicator]
nominal_all = imf("NGDPDPC") # GDP per capita, current US$ (market exchange rate)
ppp_all = imf("PPPPC") # GDP per capita, current international $ (PPP)
gdp_all = imf("NGDPD") # GDP, current US$ billions
missing = [c for c in OECD38 if c not in nominal_all or c not in ppp_all]
assert not missing, f"IMF data missing for {missing}"
no_year = [c for c in OECD38 if YEAR not in nominal_all[c] or YEAR not in ppp_all[c]]
assert not no_year, f"no {YEAR} projection for {no_year}"
print(f"\nall 38 members present, all have a {YEAR} projection")
NGDPDPC: cached PPPPC: cached NGDPD: cached all 38 members present, all have a 2026 projection
# One tidy frame, every member, every year the IMF publishes.
records = []
for iso, name in OECD38.items():
years = set(nominal_all[iso]) & set(ppp_all[iso])
for y in sorted(years):
records.append({"iso3": iso, "country": name, "year": int(y),
"gdp_pc_usd": nominal_all[iso][y], "gdp_pc_ppp": ppp_all[iso][y]})
weo = pd.DataFrame(records)
weo["price_level"] = weo["gdp_pc_usd"] / weo["gdp_pc_ppp"] * 100
weo.to_csv(CACHE / "imf-weo-oecd.csv", index=False)
print(f"{len(weo):,} country-years, {weo.year.min()}–{weo.year.max()}")
weo.head()
1,896 country-years, 1980–2031
| iso3 | country | year | gdp_pc_usd | gdp_pc_ppp | price_level | |
|---|---|---|---|---|---|---|
| 0 | AUS | Australia | 1980 | 11021.309 | 10298.991 | 107.013483 |
| 1 | AUS | Australia | 1981 | 12544.129 | 11553.350 | 108.575686 |
| 2 | AUS | Australia | 1982 | 12250.122 | 12074.479 | 101.454663 |
| 3 | AUS | Australia | 1983 | 11606.529 | 12330.927 | 94.125357 |
| 4 | AUS | Australia | 1984 | 12590.852 | 13418.795 | 93.829975 |
3. The table the essay is built on¶
Comparative price level is the ratio of the two conversions, indexed so the United States is 100. The United States sits at exactly 100 by construction — its market rate is its PPP rate — which is a useful check that the arithmetic is the right way up.
year = weo[weo.year == int(YEAR)].set_index("country")
tbl = pd.DataFrame({
"nominal $": year["gdp_pc_usd"].round(0).astype(int),
"PPP $": year["gdp_pc_ppp"].round(0).astype(int),
"price level": year["price_level"].round(1),
})
tbl["rank nominal"] = tbl["nominal $"].rank(ascending=False).astype(int)
tbl["rank PPP"] = tbl["PPP $"].rank(ascending=False).astype(int)
tbl["rank change"] = tbl["rank nominal"] - tbl["rank PPP"] # negative = falls
tbl = tbl.sort_values("nominal $", ascending=False)
assert tbl.loc["United States", "price level"] == 100.0, "US must be 100 by construction"
display(tbl)
| nominal $ | PPP $ | price level | rank nominal | rank PPP | rank change | |
|---|---|---|---|---|---|---|
| country | ||||||
| Luxembourg | 158733 | 156719 | 101.3 | 1 | 2 | -1 |
| Ireland | 140186 | 159129 | 88.1 | 2 | 1 | 1 |
| Switzerland | 126177 | 105680 | 119.4 | 3 | 4 | -1 |
| Iceland | 110048 | 82730 | 133.0 | 4 | 8 | -4 |
| Norway | 105877 | 115548 | 91.6 | 5 | 3 | 2 |
| United States | 94430 | 94430 | 100.0 | 6 | 5 | 1 |
| Denmark | 83445 | 89667 | 93.1 | 7 | 6 | 1 |
| Netherlands | 79918 | 87773 | 91.1 | 8 | 7 | 1 |
| Australia | 75648 | 74755 | 101.2 | 9 | 13 | -4 |
| Sweden | 70676 | 77094 | 91.7 | 10 | 11 | -1 |
| Israel | 69804 | 59095 | 118.1 | 11 | 26 | -15 |
| Austria | 67761 | 78334 | 86.5 | 12 | 10 | 2 |
| Germany | 65303 | 76747 | 85.1 | 13 | 12 | 1 |
| Belgium | 65112 | 78607 | 82.8 | 14 | 9 | 5 |
| United Kingdom | 61056 | 67585 | 90.3 | 15 | 18 | -3 |
| Canada | 60305 | 70006 | 86.1 | 16 | 14 | 2 |
| Finland | 60130 | 68861 | 87.3 | 17 | 15 | 2 |
| France | 52083 | 68567 | 76.0 | 18 | 17 | 1 |
| New Zealand | 52023 | 58308 | 89.2 | 19 | 27 | -8 |
| Italy | 46505 | 65761 | 70.7 | 20 | 19 | 1 |
| Spain | 41563 | 59187 | 70.2 | 21 | 25 | -4 |
| Slovenia | 40630 | 60664 | 67.0 | 22 | 22 | 0 |
| Czechia | 39795 | 63550 | 62.6 | 23 | 20 | 3 |
| Estonia | 37718 | 51653 | 73.0 | 24 | 29 | -5 |
| Korea | 37412 | 68624 | 54.5 | 25 | 16 | 9 |
| Lithuania | 36545 | 61052 | 59.9 | 26 | 21 | 5 |
| Japan | 35703 | 59207 | 60.3 | 27 | 24 | 3 |
| Portugal | 35434 | 52841 | 67.1 | 28 | 28 | 0 |
| Poland | 31336 | 59792 | 52.4 | 29 | 23 | 6 |
| Slovakia | 31242 | 49466 | 63.2 | 30 | 31 | -1 |
| Greece | 29696 | 47175 | 62.9 | 31 | 32 | -1 |
| Latvia | 28913 | 45840 | 63.1 | 32 | 34 | -2 |
| Hungary | 28430 | 50570 | 56.2 | 33 | 30 | 3 |
| Costa Rica | 20299 | 34157 | 59.4 | 34 | 36 | -2 |
| Chile | 20240 | 37336 | 54.2 | 35 | 35 | 0 |
| Türkiye | 19018 | 46672 | 40.7 | 36 | 33 | 3 |
| Mexico | 15779 | 26643 | 59.2 | 37 | 37 | 0 |
| Colombia | 10104 | 23576 | 42.9 | 38 | 38 | 0 |
4. Assert the published figures¶
This is the part that keeps the essay honest. Every number that appears in the prose, the key-numbers strip or a chart caption is re-derived here and asserted. If the IMF revises its projections, these fail — loudly — instead of the essay quietly drifting away from its source.
pl = tbl["price level"]
isr = tbl.loc["Israel"]
avg = round(pl.mean(), 1)
checks = {
"Israel nominal GDP per person = $69,804": (int(isr["nominal $"]), 69804),
"Israel PPP GDP per person = $59,095": (int(isr["PPP $"]), 59095),
"Israel ranks 11th by nominal": (int(isr["rank nominal"]), 11),
"Israel ranks 26th by PPP": (int(isr["rank PPP"]), 26),
"…a fall of 15 places": (int(isr["rank nominal"] - isr["rank PPP"]), -15),
"Israel price level = 118.1": (round(float(isr["price level"]), 1), 118.1),
"Israel is 3rd most expensive": (int(pl.rank(ascending=False)["Israel"]), 3),
"OECD simple average price level = 77.7": (float(avg), 77.7),
"Israel is +52% above that average": (round((isr["price level"] / avg - 1) * 100), 52),
"Israel GDP ≈ $720bn": (round(gdp_all["ISR"][YEAR]), 720),
# rank change is nominal minus PPP, so a fall is negative and the
# biggest non-Israel fall is the minimum.
"next biggest fall is New Zealand's 8": (int(-tbl["rank change"].drop("Israel").min()), 8),
}
width = max(len(k) for k in checks)
ok = True
for label, (got, want) in checks.items():
good = got == want
ok &= good
print(f"{'PASS' if good else 'FAIL'} {label:<{width}} computed {got!r:>10} essay says {want!r}")
assert ok, "the essay and the source no longer agree"
print("\nEvery published figure reproduces from the IMF series.")
PASS Israel nominal GDP per person = $69,804 computed 69804 essay says 69804 PASS Israel PPP GDP per person = $59,095 computed 59095 essay says 59095 PASS Israel ranks 11th by nominal computed 11 essay says 11 PASS Israel ranks 26th by PPP computed 26 essay says 26 PASS …a fall of 15 places computed -15 essay says -15 PASS Israel price level = 118.1 computed 118.1 essay says 118.1 PASS Israel is 3rd most expensive computed 3 essay says 3 PASS OECD simple average price level = 77.7 computed 77.7 essay says 77.7 PASS Israel is +52% above that average computed 52 essay says 52 PASS Israel GDP ≈ $720bn computed 720 essay says 720 PASS next biggest fall is New Zealand's 8 computed 8 essay says 8 Every published figure reproduces from the IMF series.
Every one. The essay's numbers are the IMF's, unmodified.
5. The fifteen-place fall¶
Most countries move a few places between the two rankings. The essay's claim is that Israel's move is not a few places and is not close to anybody else's.
moves = tbl[["rank nominal", "rank PPP", "rank change", "price level"]] \
.sort_values("rank change")
print("Biggest falls (expensive relative to output):")
display(moves.head(6))
print("Biggest climbs (cheap relative to output):")
display(moves.tail(6).iloc[::-1])
second = moves["rank change"].drop("Israel").min()
print(f"\nIsrael falls {-int(moves.loc['Israel','rank change'])} places; "
f"next biggest fall is {-int(second)}. "
f"Ratio {moves.loc['Israel','rank change'] / second:.2f}×.")
Biggest falls (expensive relative to output):
| rank nominal | rank PPP | rank change | price level | |
|---|---|---|---|---|
| country | ||||
| Israel | 11 | 26 | -15 | 118.1 |
| New Zealand | 19 | 27 | -8 | 89.2 |
| Estonia | 24 | 29 | -5 | 73.0 |
| Iceland | 4 | 8 | -4 | 133.0 |
| Spain | 21 | 25 | -4 | 70.2 |
| Australia | 9 | 13 | -4 | 101.2 |
Biggest climbs (cheap relative to output):
| rank nominal | rank PPP | rank change | price level | |
|---|---|---|---|---|
| country | ||||
| Korea | 25 | 16 | 9 | 54.5 |
| Poland | 29 | 23 | 6 | 52.4 |
| Belgium | 14 | 9 | 5 | 82.8 |
| Lithuania | 26 | 21 | 5 | 59.9 |
| Czechia | 23 | 20 | 3 | 62.6 |
| Türkiye | 36 | 33 | 3 | 40.7 |
Israel falls 15 places; next biggest fall is 8. Ratio 1.88×.
Israel falls fifteen; the next largest fall in the OECD is New Zealand's eight. Korea climbs nine places and Poland six, for the mirror-image reason — their prices are low relative to what they produce.
6. How expensive, ranked¶
order = pl.sort_values(ascending=False)
fig, ax = plt.subplots(figsize=(7.6, 8.6))
colors = [BRICK if n == "Israel" else SAND for n in order.index]
ax.barh(range(len(order)), order.values, color=colors, height=.72)
ax.set_yticks(range(len(order)), order.index)
ax.tick_params(axis="y", length=0)
ax.invert_yaxis()
for i, (n, v) in enumerate(order.items()):
ax.text(v + 1.4, i, f"{v:.1f}", va="center", fontsize=8,
color=INK if n == "Israel" else MUTED,
fontweight="bold" if n == "Israel" else "normal")
for lbl in ax.get_yticklabels():
if lbl.get_text() == "Israel":
lbl.set_color(BRICK); lbl.set_fontweight("bold")
ax.axvline(100, color=MUTED, lw=1, ls="-", zorder=0)
ax.axvline(avg, color=AMBER, lw=1.2, ls="--", zorder=0)
ax.text(100, -1.4, "US = 100", ha="center", fontsize=8, color=MUTED)
ax.text(avg, -1.4, f"OECD avg {avg}", ha="center", fontsize=8, color=AMBER)
ax.set_xlim(0, 145)
ax.set_xlabel("comparative price level, United States = 100")
ax.grid(axis="x", color="#EFE5CD", lw=.8)
ax.set_axisbelow(True)
ax.set_title("Third most expensive country in the OECD", pad=22)
fig.text(.02, .04, f"IMF World Economic Outlook, {YEAR} projections · "
"price level = nominal ÷ PPP GDP per person", fontsize=7.5, color=MUTED)
plt.show()
7. More expensive than output explains¶
Richer countries are more expensive — that is the Balassa–Samuelson effect and it is not a scandal. The essay's sharper claim is that Israel is expensive relative to what Israel produces. Fit a line and read the residual.
The essay drops Luxembourg and Ireland from this plot, and says why: both have GDP inflated by corporate accounting that has little to do with what happens in their shops. That is a judgement call, so the next cell makes it one of four, and runs the whole thing on the 2024 outturn as well as the 2026 projection.
def ols(frame, log_x):
x = np.log(frame["gdp_pc_ppp"].values) if log_x else frame["gdp_pc_ppp"].values
y = frame["price_level"].values
b = ((x - x.mean()) * (y - y.mean())).sum() / ((x - x.mean()) ** 2).sum()
a = y.mean() - b * x.mean()
pred = a + b * x
resid = y - pred
r2 = 1 - (resid ** 2).sum() / ((y - y.mean()) ** 2).sum()
return a, b, pred, resid, r2
def spec(yr, drop_outliers, log_x):
f = weo[weo.year == yr].set_index("country")
if drop_outliers:
f = f.drop(["Luxembourg", "Ireland"])
a, b, pred, resid, r2 = ols(f, log_x)
f = f.assign(predicted=pred, residual=resid, gap_pct=resid / pred * 100)
ranked = f["gap_pct"].rank(ascending=False).astype(int)
return f, {
"year": yr,
"x": "log" if log_x else "linear",
"sample": "36 (drop LUX, IRL)" if drop_outliers else "all 38",
"R²": round(r2, 3),
"Israel actual": round(f.loc["Israel", "price_level"], 1),
"Israel predicted": round(f.loc["Israel", "predicted"], 1),
"Israel gap %": round(f.loc["Israel", "gap_pct"]),
"Israel rank": int(ranked["Israel"]),
"runner-up": f["gap_pct"].drop("Israel").idxmax(),
}
rows, frames = [], {}
for yr in (2024, int(YEAR)):
for log_x in (False, True):
for drop in (True, False):
f, row = spec(yr, drop, log_x)
frames[(yr, log_x, drop)] = f
rows.append(row)
robust = pd.DataFrame(rows)
display(robust)
print(f"Israel is the largest positive residual in "
f"{(robust['Israel rank'] == 1).sum()} of {len(robust)} specifications.")
| year | x | sample | R² | Israel actual | Israel predicted | Israel gap % | Israel rank | runner-up | |
|---|---|---|---|---|---|---|---|---|---|
| 0 | 2024 | linear | 36 (drop LUX, IRL) | 0.572 | 100.3 | 66.9 | 50 | 1 | New Zealand |
| 1 | 2024 | linear | all 38 | 0.448 | 100.3 | 67.2 | 49 | 1 | Iceland |
| 2 | 2024 | log | 36 (drop LUX, IRL) | 0.548 | 100.3 | 69.4 | 45 | 1 | Mexico |
| 3 | 2024 | log | all 38 | 0.527 | 100.3 | 68.8 | 46 | 1 | Iceland |
| 4 | 2026 | linear | 36 (drop LUX, IRL) | 0.507 | 118.1 | 72.7 | 62 | 1 | Iceland |
| 5 | 2026 | linear | all 38 | 0.376 | 118.1 | 73.0 | 62 | 1 | Iceland |
| 6 | 2026 | log | 36 (drop LUX, IRL) | 0.480 | 118.1 | 75.3 | 57 | 1 | Iceland |
| 7 | 2026 | log | all 38 | 0.453 | 118.1 | 74.7 | 58 | 1 | Iceland |
Israel is the largest positive residual in 8 of 8 specifications.
Israel is the single biggest positive residual in every one of the eight. The size of the gap moves a lot — from +45% to +62% — and the essay quotes the largest of them, which is worth saying out loud. But no specification puts any other OECD country further above the line, and none of them puts Israel near it.
The headline figure in the essay (predicted 73, actual 118, gap +62%) is the 2026 linear fit with Luxembourg and Ireland dropped, exactly as its caption states.
f = frames[(int(YEAR), False, True)] # the essay's own specification
a, b, pred, resid, r2 = ols(f, log_x=False)
fig, ax = plt.subplots(figsize=(8, 5))
x, y = f["gdp_pc_ppp"], f["price_level"]
grid = np.linspace(x.min() * .95, x.max() * 1.03, 50)
ax.plot(grid, a + b * grid, color=MUTED, lw=1.4, ls="--", zorder=2,
label=f"OLS fit (R² = {r2:.2f})")
others = f.drop("Israel")
ax.scatter(others["gdp_pc_ppp"], others["price_level"], s=34, color=SAND, zorder=3,
edgecolor=PAPER, linewidth=1.2, label="OECD member")
ix, iy = f.loc["Israel", "gdp_pc_ppp"], f.loc["Israel", "price_level"]
ax.plot([ix, ix], [a + b * ix, iy], color=BRICK, lw=1.2, ls=":", zorder=4)
ax.scatter([ix], [iy], s=110, color=BRICK, zorder=5, edgecolor=PAPER, linewidth=1.5,
label="Israel")
ax.annotate(f"ISRAEL\n{f.loc['Israel','gap_pct']:+.0f}% above the line",
(ix, iy), textcoords="offset points", xytext=(12, 4),
fontsize=9, fontweight="bold", color=BRICK, linespacing=1.4)
for n in ("Switzerland", "Iceland", "Norway", "United States", "Korea", "Türkiye"):
ax.annotate(n, (f.loc[n, "gdp_pc_ppp"], f.loc[n, "price_level"]),
textcoords="offset points", xytext=(7, -3), fontsize=7.5, color=MUTED)
ax.xaxis.set_major_formatter(lambda v, _: f"${v/1000:,.0f}k")
ax.set_xlabel("output per person, PPP international $")
ax.set_ylabel("comparative price level (US = 100)")
ax.grid(color="#EFE5CD", lw=.8)
ax.set_axisbelow(True)
ax.legend(loc="upper left")
ax.set_title("Israel sits furthest above the line of any OECD country", pad=10)
fig.text(.02, -.02, f"IMF WEO {YEAR} projections · Luxembourg and Ireland excluded: "
"their GDP is inflated by corporate accounting", fontsize=7.5, color=MUTED)
plt.show()
print(f"Israel: predicted {f.loc['Israel','predicted']:.0f}, "
f"actual {f.loc['Israel','price_level']:.1f}, "
f"gap {f.loc['Israel','gap_pct']:+.0f}%")
print("\nfurthest above the line:")
display(f.nlargest(5, "gap_pct")[["price_level", "predicted", "gap_pct"]].round(1))
Israel: predicted 73, actual 118.1, gap +62% furthest above the line:
| price_level | predicted | gap_pct | |
|---|---|---|---|
| country | |||
| Israel | 118.1 | 72.7 | 62.4 |
| Iceland | 133.0 | 90.7 | 46.7 |
| New Zealand | 89.2 | 72.1 | 23.7 |
| Mexico | 59.2 | 48.1 | 23.2 |
| Australia | 101.2 | 84.6 | 19.6 |
8. Is 118 a normal number for Israel?¶
Everything so far takes the 2026 projection at face value. It is worth asking how that figure compares with Israel's own history, because the price level is a ratio of two things, and one of them — the market exchange rate — moves a great deal faster than prices in shops do.
isr_ts = weo[(weo.iso3 == "ISR") & (weo.year >= 1995)].set_index("year")
actual = isr_ts[isr_ts.index <= 2024]
proj = isr_ts[isr_ts.index >= 2024]
fig, ax = plt.subplots(figsize=(8.4, 4.2))
ax.axvspan(2024.5, isr_ts.index.max(), color=MARI, alpha=.16, zorder=0)
ax.plot(actual.index, actual["price_level"], color=MARK, lw=2.2, zorder=3, label="outturn")
ax.plot(proj.index, proj["price_level"], color=MARK, lw=2.2, ls=":", zorder=3,
label="IMF projection")
ax.axhline(100, color=MUTED, lw=1, zorder=1)
ax.text(1996, 101.5, "United States = 100", fontsize=7.5, color=MUTED)
ax.text(isr_ts.index.max() - .2, ax.get_ylim()[0] + 3, "projected", ha="right",
fontsize=8, color=AMBER, fontweight="bold")
# 2024 is labelled below and left so it does not collide with the climb.
for yr, dx, dy, ha in ((2024, -8, -26, "right"), (int(YEAR), 6, 10, "left")):
v = isr_ts.loc[yr, "price_level"]
ax.scatter([yr], [v], s=52, color=BRICK, zorder=4, edgecolor=PAPER, linewidth=1.4)
ax.annotate(f"{yr}\n{v:.1f}", (yr, v), textcoords="offset points", xytext=(dx, dy),
ha=ha, fontsize=8.5, fontweight="bold", color=BRICK, linespacing=1.3)
ax.set_ylabel("Israel's comparative price level")
ax.set_xlabel("year")
ax.grid(color="#EFE5CD", lw=.8)
ax.set_axisbelow(True)
ax.legend(loc="upper left")
ax.set_title("The 118 is a projection, and a record", pad=10)
plt.show()
hist = isr_ts.loc[1995:2024, "price_level"]
print(f"1995–2024 outturn: mean {hist.mean():.1f}, min {hist.min():.1f} "
f"({hist.idxmin()}), max {hist.max():.1f} ({hist.idxmax()})")
print(f"2024 outturn : {isr_ts.loc[2024,'price_level']:.1f}")
print(f"{YEAR} projection : {isr_ts.loc[int(YEAR),'price_level']:.1f}")
print(f"\nnominal GDP per person {isr_ts.loc[2024,'gdp_pc_usd']:,.0f} -> "
f"{isr_ts.loc[int(YEAR),'gdp_pc_usd']:,.0f} "
f"({isr_ts.loc[int(YEAR),'gdp_pc_usd']/isr_ts.loc[2024,'gdp_pc_usd']-1:+.1%})")
print(f"PPP GDP per person {isr_ts.loc[2024,'gdp_pc_ppp']:,.0f} -> "
f"{isr_ts.loc[int(YEAR),'gdp_pc_ppp']:,.0f} "
f"({isr_ts.loc[int(YEAR),'gdp_pc_ppp']/isr_ts.loc[2024,'gdp_pc_ppp']-1:+.1%})")
1995–2024 outturn: mean 99.4, min 84.0 (2006), max 113.0 (2021) 2024 outturn : 100.3 2026 projection : 118.1 nominal GDP per person 54,294 -> 69,804 (+28.6%) PPP GDP per person 54,126 -> 59,095 (+9.2%)
This is the most important thing in the notebook, and the essay understates it.
Israel's price level over 1995–2024 ranges from 84.0 (2006) to 113.0 (2021) and averages 99.4 — that is, Israel has spent three decades hovering around the United States. The 2024 outturn was 100.3. The 118.1 the essay quotes is a projection for a year that has not finished, and it is the highest figure anywhere in the series, outturn or forecast.
The mechanism is visible in the last two lines. Between 2024 and 2026 the IMF has Israel's nominal dollar output per person rising 28.6% while its PPP output per person rises 9.2%. Very little of that is shops getting dearer. Most of it is the shekel, which is exactly the first of the three objections the essay itself raises — the essay says "some of the 118 is the exchange rate", and the honest answer is that most of the recent increase is.
What survives is the ranking. On the 2024 outturn Israel is still the third most expensive country in the OECD, still 40% above the OECD average of 71.8, and still the largest positive residual in the regression at +50%. The direction and the ordering hold on settled data. It is the headline magnitude that sits at the top of its own range.
y24 = weo[weo.year == 2024].set_index("country")
print(f"2024 outturn — OECD simple average price level: {y24['price_level'].mean():.1f}")
print(f"2024 outturn — Israel {y24.loc['Israel','price_level']:.1f}, "
f"rank {int(y24['price_level'].rank(ascending=False)['Israel'])} of 38, "
f"{y24.loc['Israel','price_level']/y24['price_level'].mean()-1:+.0%} above the average")
display(y24["price_level"].round(1).nlargest(6))
2024 outturn — OECD simple average price level: 71.8 2024 outturn — Israel 100.3, rank 3 of 38, +40% above the average
country Iceland 110.0 Switzerland 109.7 Israel 100.3 United States 100.0 Australia 94.3 Luxembourg 92.5 Name: price_level, dtype: float64
9. An independent estimate¶
Everything above comes from one institution's PPP model. The World Bank publishes its own, built on the International Comparison Program's price surveys — a different institution, a different survey, a different vintage. Dividing its PPP conversion factor by the market exchange rate gives the same quantity by a different route.
The essay already concedes that "reasonable methods disagree" and says the OECD's version of the gap is "closer to 35%". Worth checking whether that concession is the right size.
WB = ("https://api.worldbank.org/v2/country/{iso}/indicator/{ind}"
"?format=json&per_page=2000&date=2015:2024")
def worldbank(indicator):
CACHE.mkdir(exist_ok=True)
path = CACHE / f"worldbank-{indicator}.json"
if not path.exists():
print(f"downloading {indicator} …")
path.write_bytes(fetch(WB.format(iso=";".join(OECD38), ind=indicator)))
else:
print(f"{indicator}: cached")
payload = json.loads(path.read_text())
return pd.DataFrame([
{"iso3": r["countryiso3code"], "year": int(r["date"]), indicator: r["value"]}
for r in payload[1] if r["value"] is not None
])
ppp_factor = worldbank("PA.NUS.PPP") # PPP conversion factor, GDP (LCU per int'l $)
fx_rate = worldbank("PA.NUS.FCRF") # official exchange rate (LCU per US$)
wb = ppp_factor.merge(fx_rate, on=["iso3", "year"])
wb["price_level_wb"] = wb["PA.NUS.PPP"] / wb["PA.NUS.FCRF"] * 100
wb = wb.sort_values("year").groupby("iso3").tail(1) # latest year per country
wb["country"] = wb["iso3"].map(OECD38)
wb.to_csv(CACHE / "worldbank-price-level-oecd.csv", index=False)
wb_pl = wb.set_index("country")["price_level_wb"]
print(f"\nWorld Bank, latest available year ({wb.year.mode()[0]}), {len(wb)} members")
print(f" Israel: {wb_pl['Israel']:.1f} rank {int(wb_pl.rank(ascending=False)['Israel'])} of {len(wb_pl)}")
print(f" OECD simple average: {wb_pl.mean():.1f}")
print(f" Israel vs average: {wb_pl['Israel']/wb_pl.mean()-1:+.1%}")
PA.NUS.PPP: cached PA.NUS.FCRF: cached World Bank, latest available year (2024), 38 members Israel: 94.7 rank 4 of 38 OECD simple average: 70.2 Israel vs average: +34.9%
compare = pd.DataFrame({
f"IMF {YEAR} (projection)": pl,
"IMF 2024 (outturn)": y24["price_level"].round(1),
"World Bank / ICP 2024": wb_pl.round(1),
}).sort_values(f"IMF {YEAR} (projection)", ascending=False)
fig, ax = plt.subplots(figsize=(8, 8.4))
yy = np.arange(len(compare))
ax.hlines(yy, compare["World Bank / ICP 2024"], compare[f"IMF {YEAR} (projection)"],
color=SAND, lw=1.6, zorder=1)
ax.scatter(compare["World Bank / ICP 2024"], yy, s=32, color=AMBER, zorder=3,
label="World Bank / ICP, 2024")
ax.scatter(compare[f"IMF {YEAR} (projection)"], yy, s=32, color=MARK, zorder=3,
label=f"IMF WEO, {YEAR} projection")
i = list(compare.index).index("Israel")
ax.scatter(compare[f"IMF {YEAR} (projection)"].iloc[i], i, s=90, color=BRICK, zorder=4)
ax.scatter(compare["World Bank / ICP 2024"].iloc[i], i, s=90, color=BRICK, zorder=4)
ax.set_yticks(yy, compare.index)
ax.tick_params(axis="y", length=0)
ax.invert_yaxis()
for lbl in ax.get_yticklabels():
if lbl.get_text() == "Israel":
lbl.set_color(BRICK); lbl.set_fontweight("bold")
ax.axvline(100, color=MUTED, lw=1, zorder=0)
ax.set_xlabel("comparative price level, United States = 100")
ax.grid(axis="x", color="#EFE5CD", lw=.8)
ax.set_axisbelow(True)
ax.legend(loc="lower right")
ax.set_title("Two institutions, two answers, one ordering", pad=10)
plt.show()
display(compare.loc[["Iceland", "Switzerland", "Israel", "Australia",
"Norway", "Korea", "Türkiye"]])
print(f"rank correlation between the two columns: "
f"{compare[f'IMF {YEAR} (projection)'].corr(compare['World Bank / ICP 2024'], method='spearman'):.3f}")
| IMF 2026 (projection) | IMF 2024 (outturn) | World Bank / ICP 2024 | |
|---|---|---|---|
| country | |||
| Iceland | 133.0 | 110.0 | 102.8 |
| Switzerland | 119.4 | 109.7 | 110.5 |
| Israel | 118.1 | 100.3 | 94.7 |
| Australia | 101.2 | 94.3 | 90.2 |
| Norway | 91.6 | 83.1 | 85.1 |
| Korea | 54.5 | 57.6 | 59.4 |
| Türkiye | 40.7 | 38.2 | 35.2 |
rank correlation between the two columns: 0.955
The two sources rank the OECD almost identically — a Spearman correlation above 0.95 — and they disagree about Israel's level by roughly what the essay says they do. The World Bank puts Israel at 94.7 in 2024, fourth in the OECD rather than third, and +34.9% above the OECD average. The essay's sentence — "The OECD's puts it closer to 35%" — is the right order of magnitude, from an independent source it doesn't cite.
So the essay's own counterargument section is the accurate part of it. Israel is expensive relative to its output on both measures; the honest range for how expensive is something like +35% to +60%, not a single number.
10. What holds, and what doesn't¶
Holds, on every source and specification tested:
- Israel falls further between the market-rate and PPP rankings than any other OECD country, and by roughly double the next largest fall.
- Israel is among the three or four most expensive countries in the OECD.
- Israel is the largest positive residual when price level is regressed on output per person — first place in all eight specifications, on both the 2024 outturn and the 2026 projection, linear and log, with and without the Luxembourg/Ireland exclusion.
Doesn't hold as firmly as the essay implies:
- "118" is a projection at the top of a thirty-year range, not a settled measurement. The 2024 outturn is 100.3, against a 1995–2024 average of 99.4. The essay should say which year the number belongs to.
- The +62% gap is the largest of eight defensible estimates. The range is +45% to +62%, and an independent World Bank calculation lands near +35%. The essay quotes the top of the range in the body and the bottom in its caveats.
- Most of the 2024→2026 jump is the exchange rate, not shop prices: nominal dollar output per person +28.6%, PPP output per person +9.2%. The essay lists the currency as one objection among three; on these numbers it is the dominant driver of the recent move.
None of that touches the policy argument, which rests on the ranking and the sign, not on the magnitude.
11. Export the dataset the site reads¶
The charts on the essay page read assets/data.js. Writing it from here means the published
figures and this analysis cannot drift apart.
lines = [
"/* ------------------------------------------------------------------",
" data.js - every OECD number used on this site lives here.",
"",
" GENERATED FILE. Do not hand-edit.",
" Produced by analysis/israel-cost-of-living.ipynb, which pulls the",
" series from the IMF DataMapper API and asserts that every figure",
" quoted in the essay still reproduces. Re-run that notebook to",
" regenerate.",
"",
f" Source: IMF World Economic Outlook, {YEAR} projections, indicators",
" NGDPDPC and PPPPC, OECD member countries only (38).",
"",
" nominal = GDP per capita, current US$",
" ppp = GDP per capita, current international $ (PPP)",
"",
" Price level is DERIVED below: nominal / ppp * 100. That ratio is the",
" implied PPP conversion factor over the market exchange rate -- i.e.",
" the comparative price level, US = 100.",
" ------------------------------------------------------------------ */",
"",
"const OECD = {",
]
w = max(len(n) for n in tbl.index) + 3
for name, row in tbl.iterrows():
key = '"' + name + '":'
nom_v = int(row["nominal $"])
ppp_v = int(row["PPP $"])
lines.append(f" {key:<{w}} [{nom_v:>6}, {ppp_v:>6}],")
lines[-1] = lines[-1].rstrip(",")
lines += ["};", ""]
js = "\n".join(lines)
tail = OUT_JS.read_text().split("/* ---- derived series")[1]
OUT_JS.write_text(js + "/* ---- derived series" + tail)
print(f"wrote {OUT_JS.relative_to(HERE.parent)} ({OUT_JS.stat().st_size:,} bytes)\n")
print(js[js.index("const OECD"):][:520] + " …")
wrote assets/data.js (3,427 bytes)
const OECD = {
"Luxembourg": [158733, 156719],
"Ireland": [140186, 159129],
"Switzerland": [126177, 105680],
"Iceland": [110048, 82730],
"Norway": [105877, 115548],
"United States": [ 94430, 94430],
"Denmark": [ 83445, 89667],
"Netherlands": [ 79918, 87773],
"Australia": [ 75648, 74755],
"Sweden": [ 70676, 77094],
"Israel": [ 69804, 59095],
"Austria": [ 67761, 78334],
"Germany": [ 65303, 76747],
"Belgium" …
# Final self-check: the file just written must still carry the headline figures.
txt = OUT_JS.read_text()
assert '"Israel":' in txt and "69804" in txt and "59095" in txt
head = txt[txt.index("const OECD"):txt.index("/* ---- derived series")]
assert head.count("],") + 1 == 38, "expected 38 country rows"
print(f"38 countries written · Israel {int(isr['nominal $']):,} nominal / "
f"{int(isr['PPP $']):,} PPP · price level {isr['price level']}")
print(f"cached inputs: {sorted(p.name for p in CACHE.glob('*'))}")
38 countries written · Israel 69,804 nominal / 59,095 PPP · price level 118.1 cached inputs: ['imf-weo-NGDPD.json', 'imf-weo-NGDPDPC.json', 'imf-weo-PPPPC.json', 'imf-weo-oecd.csv', 'pse_admpop_adm1_2023.csv', 'worldbank-PA.NUS.FCRF.json', 'worldbank-PA.NUS.PPP.json', 'worldbank-price-level-oecd.csv']