Scrape Numbeo Healthcare Index by City with Python
Numbeo's health care rankings page is already structured like a dataset.
You do not need to click around, scroll endlessly, or reverse-engineer a browser app. The current page exposes a real HTML table with city-level index values you can parse straight into Python.
In this guide we will collect:
- city name
- Health Care Index
- Health Care Exp. Index
- optional ranking position
Then we will export clean CSV and build a simple comparison slice for selected cities.

Numbeo pages are table-friendly, but if you schedule city snapshots across multiple ranking pages, ProxiesAPI gives you a simple retry and proxy layer without changing your parsing code.
What we are scraping
The current rankings URL is:
https://www.numbeo.com/health-care/rankings_current.jsp
At the time of writing, the main page contains:
- a heading:
Current Health Care Index - an HTML table with
id="t2" - columns for:
CityHealth Care IndexHealth Care Exp. Index
That is a strong signal that plain requests + BeautifulSoup is the right tool.
Setup
python3 -m venv .venv
source .venv/bin/activate
pip install requests beautifulsoup4 lxml pandas
Step 1: Fetch the rankings page
from __future__ import annotations
import os
import random
import time
from typing import Optional
import requests
RANKINGS_URL = "https://www.numbeo.com/health-care/rankings_current.jsp"
TIMEOUT = (10, 30)
PROXIESAPI_PROXY_URL = os.getenv("PROXIESAPI_PROXY_URL")
session = requests.Session()
session.headers.update(
{
"User-Agent": (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/126.0.0.0 Safari/537.36"
),
"Accept-Language": "en-US,en;q=0.9",
}
)
def proxy_config() -> Optional[dict[str, str]]:
if not PROXIESAPI_PROXY_URL:
return None
return {"http": PROXIESAPI_PROXY_URL, "https": PROXIESAPI_PROXY_URL}
def fetch(url: str, *, max_retries: int = 4) -> str:
last_error = None
for attempt in range(1, max_retries + 1):
try:
response = session.get(url, timeout=TIMEOUT, proxies=proxy_config())
if response.status_code in (429, 500, 502, 503, 504):
raise requests.HTTPError(f"retryable status {response.status_code}")
response.raise_for_status()
return response.text
except Exception as exc: # noqa: BLE001
last_error = exc
if attempt == max_retries:
break
time.sleep(min(10, attempt * 2) + random.random())
raise RuntimeError(f"failed to fetch {url}: {last_error}")
For one-off development runs, you can scrape the page directly. If you are scheduling multiple datasets or rotating across several public ranking pages, enabling a proxy layer is safer.
Step 2: Parse the HTML table
The table is not hidden. It is rendered directly in the page as table#t2.
import re
from bs4 import BeautifulSoup
def clean(text: str | None) -> str | None:
if not text:
return None
value = re.sub(r"\s+", " ", text).strip()
return value or None
def to_float(text: str | None) -> float | None:
if not text:
return None
try:
return float(text.replace(",", ""))
except ValueError:
return None
def parse_rankings(html: str) -> list[dict]:
soup = BeautifulSoup(html, "lxml")
table = soup.select_one("table#t2")
if table is None:
raise RuntimeError("Numbeo table #t2 not found")
rows: list[dict] = []
for idx, tr in enumerate(table.select("tbody tr"), start=1):
city_link = tr.select_one("td.cityOrCountryInIndicesTable a")
cells = [clean(td.get_text(" ", strip=True)) for td in tr.select("td")]
if len(cells) < 4:
continue
rows.append(
{
"rank": idx,
"city": city_link.get_text(" ", strip=True) if city_link else cells[1],
"city_url": city_link.get("href") if city_link else None,
"health_care_index": to_float(cells[2]),
"health_care_expectation_index": to_float(cells[3]),
}
)
return rows
One useful quirk here: the first table cell is blank in the live HTML, so deriving rank from row order is more reliable than trusting that first td.
Step 3: Build a DataFrame for comparisons
import pandas as pd
def build_dataframe(rows: list[dict]) -> pd.DataFrame:
df = pd.DataFrame(rows)
if df.empty:
return df
df["gap"] = df["health_care_expectation_index"] - df["health_care_index"]
return df.sort_values("rank", ascending=True)
def compare_cities(df: pd.DataFrame, cities: list[str]) -> pd.DataFrame:
wanted = {name.lower() for name in cities}
mask = df["city"].str.lower().isin(wanted)
return df.loc[
mask,
["rank", "city", "health_care_index", "health_care_expectation_index", "gap"],
]
Now you have something immediately useful for:
- relocation research
- expat comparison tools
- editorial research
- internal benchmarking datasets
Step 4: Export CSV and JSON
import json
def export_outputs(df: pd.DataFrame) -> None:
df.to_csv("numbeo_health_care_rankings.csv", index=False)
with open("numbeo_health_care_rankings.json", "w", encoding="utf-8") as f:
json.dump(df.to_dict(orient="records"), f, ensure_ascii=False, indent=2)
if __name__ == "__main__":
html = fetch(RANKINGS_URL)
rows = parse_rankings(html)
df = build_dataframe(rows)
export_outputs(df)
print(df.head(10).to_string(index=False))
sample = compare_cities(df, ["Taipei, Taiwan", "Seoul, South Korea", "Helsinki, Finland"])
print(sample.to_string(index=False))
Example output shape:
rank city health_care_index health_care_expectation_index gap
1 Kaohsiung, Taiwan 89.5 165.4 75.9
2 Taipei, Taiwan 87.2 160.5 73.3
3 Makati, Philippines 85.0 156.6 71.6
When to keep it this simple
This Numbeo page is a good example of when not to overbuild:
- the table is present in raw HTML
- the fields are tabular and typed
- there is no login wall
- the parsing logic is small enough to debug in minutes
That means a browser would mostly add latency and failure modes without adding value.
Practical notes
- Save the scrape date with the dataset if you plan to compare snapshots over time.
- Keep numeric fields as floats so you can sort and chart them cleanly.
- If you later scrape city detail pages, store them as a separate dataset instead of overloading this rankings table.
- Be polite with request frequency. Public does not mean infinite free polling.
Comparison table: direct scraping vs browser automation
| Task | Direct HTML scraper | Browser flow |
|---|---|---|
| Pull current rankings table | Best option | Unnecessary |
| Screenshot page for editorial use | Possible, but awkward | Better |
| Interact with filters if the page changes later | Limited | Better |
| Scheduled export of the same table | Best option | Overkill |
For the core dataset, direct HTML wins.
Where ProxiesAPI fits
ProxiesAPI is not required to parse the table itself. The value shows up when this tutorial turns into an operational pipeline:
- collecting several Numbeo ranking pages on a schedule
- retrying around occasional timeouts or transient errors
- sharing one network layer across many scrapers
- swapping between direct and proxied traffic without rewriting parsing code
That is the right mental model: not "proxy everything forever," but "add stability when the scraper becomes a system."
If your goal is a simple city-level healthcare dataset, this page is one of the cleaner public targets you can scrape.
Numbeo pages are table-friendly, but if you schedule city snapshots across multiple ranking pages, ProxiesAPI gives you a simple retry and proxy layer without changing your parsing code.