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 health care rankings page

Turn repeatable Numbeo collection on with ProxiesAPI

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:
    • City
    • Health Care Index
    • Health 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

TaskDirect HTML scraperBrowser flow
Pull current rankings tableBest optionUnnecessary
Screenshot page for editorial usePossible, but awkwardBetter
Interact with filters if the page changes laterLimitedBetter
Scheduled export of the same tableBest optionOverkill

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.

Turn repeatable Numbeo collection on with ProxiesAPI

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.

Related guides

Scrape Numbeo Quality of Life Index by City with Python
Extract Numbeo's city-level quality-of-life scores, safety, traffic, pollution, and climate indicators into a clean dataset with Python and ProxiesAPI.
tutorial#python#numbeo#web-scraping
Scrape Numbeo Crime Index by City with Python + ProxiesAPI
Extract city crime rankings, safety scores, and comparison-ready rows from Numbeo's public rankings table into JSON and CSV.
tutorial#python#numbeo#web-scraping
Scrape Numbeo Cost of Living Data with Python (cities, indices, and tables)
Extract Numbeo cost-of-living tables into a structured dataset (with a screenshot), then export to JSON/CSV using ProxiesAPI-backed requests.
tutorial#python#web-scraping#beautifulsoup
Scrape GitHub Repository Contributors and Commit Activity with Python
Turn GitHub's public contributors and commit charts into a lightweight engineering-health dataset with Python, CSV export, and practical retry patterns.
tutorial#python#github#web-scraping