DEV Community

Cover image for I compared 1,489 eSIM plans in 199 countries. Price per GB varies 105x
Bitcall Team
Bitcall Team

Posted on

I compared 1,489 eSIM plans in 199 countries. Price per GB varies 105x

Disclosure: we run Bitcall, which sells travel eSIMs. The prices below come from our own catalogue, so read them as one provider's prices on one date, not a survey of the market.

We wanted to answer a simple question: how much does mobile data cost a traveller around the world? We had the data on our own site, so we turned it into a small price index. Here is how it works, with the code and a few things that surprised us.

1. The data was already structured

Every destination page on our site carries schema.org Offer objects in its JSON-LD, one per plan, with the size and validity in the offer URL fragment:

{"@type":"Offer","price":"5.23","priceCurrency":"USD", "url":"https://bitcall.io/esim/afghanistan#plan-1gb-7d"}
Enter fullscreen mode Exit fullscreen mode

So the scraper doesn't parse any HTML layout. It reads the sitemap, fetches each page and pulls the offers with one regular expression:

import re, json, urllib.request, concurrent.futures

OFFER = re.compile(
    r'"@type":"Offer","price":"([0-9.]+)","priceCurrency":"(\w+)"'    r'[^}]*?"url":"[^"]+#plan-([0-9.]+)(mb|gb)-(\d+)d"', re.I)

def get(url):
    req = urllib.request.Request(url, headers={"User-Agent": "price-index"})
    with urllib.request.urlopen(req, timeout=40) as r:
        return r.read().decode("utf-8", "replace")

def offers(url):
    plans = []
    for price, cur, size, unit, days in OFFER.findall(get(url)):
        gb = float(size) / 1000 if unit.lower() == "mb" else float(size)
        plans.append({"price": float(price), "cur": cur, "gb": gb, "days": int(days)})
    return plans

urls = []
for n in range(1, 6):
    xml = get(f"https://bitcall.io/sitemaps/esim-countries-{n}.xml")
    urls += re.findall(r"<loc>(https://bitcall.io/esim/[^<]+)</loc>", xml)

urls = sorted(set(urls))
with concurrent.futures.ThreadPoolExecutor(6) as pool:
    rows = dict(zip(urls, pool.map(offers, urls)))
Enter fullscreen mode Exit fullscreen mode

That gave 201 pages. Two of them (Caribbean Islands and Oceania) are multi-country bundles, so we dropped them and kept 199 single destinations with 1,489 fixed-data plans. Unlimited plans have no allowance to divide by, so they stay out of the per-GB numbers.

If your site already ships structured data for Google, it is also a clean, versioned API for yourself.

2. Pick the metric before you look at the results

"Price per GB" sounds obvious, but a destination can have anywhere from 1 to 22 plans, and the per-GB price falls steeply with plan size. We used two numbers per destination:

  • 1 GB plan: the cheapest plan with exactly 1 GB. This is what a weekend visitor sees first.
  • Best price per GB: the lowest price divided by allowance, among plans of 1 GB or more.
def summarise(plans):
    one = [p["price"] for p in plans if p["gb"] == 1]
    big = [p for p in plans if p["gb"] >= 1]
    best = min(big, key=lambda p: p["price"] / p["gb"])
    return {
        "gb1": min(one) if one else None,          # Guam and Nicaragua have none
        "per_gb": round(best["price"] / best["gb"], 2),
        "best_plan": (best["gb"], best["days"], best["price"]),
    }
Enter fullscreen mode Exit fullscreen mode

The 1 GB floor keeps the comparison to plans that cover more than a day of light use.

3. Use the median, not the mean

When we grouped destinations by region, a few outliers moved the averages. Oceania has only 9 destinations, and Nauru ($8.87 per GB) alone lifts its mean from $3.08 to $3.73. The median moves from $3.29 to $3.45, much less:

import statistics
for region in regions:
    xs = [d["per_gb"] for d in index if d["region"] == region]
    print(region, round(statistics.median(xs), 2), len(xs))
Enter fullscreen mode Exit fullscreen mode
Region Median best price per GB Destinations
Europe $1.04 53
Asia $1.16 29
Middle East $1.64 12
Oceania $3.45 9
Americas $3.54 54
Africa $3.60 42

Europe and Asia sit around a dollar per GB. Africa, the Americas and Oceania cost about three times as much.

4. 199 countries on one chart

A bar chart of 199 countries is unreadable, and the values span two orders of magnitude ($0.10 to $10.49). Two decisions fixed it:

  1. A log scale. Equal distances now mean equal ratios, so $0.10 to $1 takes the same space as $1 to $10.
  2. One row per region, one dot per destination (a beeswarm). Dots that would overlap get pushed into lanes above and below the row.

Every destination on one log scale

We drew it as plain SVG in Node and rasterised it with sharp, no charting library. The whole layout is about 15 lines:

const X = v => x0 + (Math.log10(v) - lmin) / (lmax - lmin) * (x1 - x0)

regions.forEach((rg, i) => {
  const cy = top + i * rowH + rowH / 2, lanes = []
  const pts = [...rg.items].sort((a, b) => a.perGb - b.perGb)
  for (const d of pts) {
    const x = X(d.perGb)
    let k = 0
    while ((lanes[k] ?? -1e9) > x - 12) k++   // first lane with room
    lanes[k] = x
    const off = k === 0 ? 0 : (k % 2 ? -1 : 1) * Math.ceil(k / 2) * 12
    svg += `<circle cx="${x}" cy="${cy + off}" r="6" fill="#ed7861"/>`
  }
  svg += `<rect x="${X(rg.median) - 1.5}" y="${cy - 52}" width="3" height="104"/>`
})
Enter fullscreen mode Exit fullscreen mode

Alternating lanes above and below the centre line keeps each row symmetric, and the dark bar marks the regional median. The striking part is that the spread inside Asia (Singapore at $0.10, Tajikistan at $6.02) is as wide as the gap between regions.

One more lesson: we published the article in 7 languages, and the charts had to follow. Long German country names ("Zentralafrikanische Republik") overflowed the label column, so the script now measures each rendered label with sharp(...).trim() and widens the column to fit. The Arabic charts are the same layout mirrored (x -> width - x, direction="rtl"), so they read right to left like the text around them.

5. What the data says

  • Price per GB runs from $0.10 in Singapore (300 GB for 30 days at $30) to $10.49 in Ethiopia (2 GB for 15 days at $20.99). That is a 105x gap.
  • A 1 GB plan costs from $2.79 in France to $15.96 in Zimbabwe. The median is $4.27.
  • 7 of the 10 most expensive destinations are in Africa, mostly because the largest plans there are only 3 to 5 GB, so you can't buy your way down to a low per-GB price.
  • Plan size matters more than destination for most trips: in Vietnam 1 GB costs $5.07, but 150 GB for 30 days works out at $0.14 per GB.

Get the data

The full write-up, with the price list for all 199 destinations, is here: Travel data prices in 199 countries. The figures and charts are free to reuse with a link to that page.

If you build something with it, a different chart or a comparison with local SIM prices, I'd like to see it.

Top comments (0)