DEV Community

Atlas Data
Atlas Data

Posted on

Compare salaries and hiring across countries with Glassdoor job data and Python

"What does a data analyst earn in London compared to New York?" sounds like a question with an easy answer. In practice salary data is scattered across reports with different dates, definitions and samples. Live job postings are a decent alternative: they show what employers are advertising right now, and Glassdoor shows a salary range for most of its listings.

In this tutorial we'll write one Python script (~70 lines) that:

  1. runs the same Glassdoor search in four cities in four countries, in parallel,
  2. saves every posting to a CSV,
  3. uses pandas to compare yearly pay ranges, how many postings list a salary at all, and which companies are hiring.

The example is "data analyst" in New York, London, Berlin and Bengaluru. Swap in any role and any locations Glassdoor covers.

Disclosure: the scraper used here is an Apify Actor published by Atlas Data, the author of this post: atlasdata/glassdoor-jobs-scraper-reliable. It is pay-per-result ($0.30 per 1,000 jobs plus a small per-run start fee), and the script sets a cost cap on every run. You could also collect the data another way; the pandas part applies either way.

Setup

pip install apify-client pandas   # tested with apify-client 3.4 and pandas 3.1
export APIFY_TOKEN=...             # https://console.apify.com/settings/integrations
Enter fullscreen mode Exit fullscreen mode

Glassdoor blocks datacenter IPs, so the Actor connects through Apify residential proxies. They are included in the per-job price, so there is nothing to configure.

What one result looks like

The input mirrors Glassdoor's own search: keywords (each keyword is a separate search), location (a city, state or country as Glassdoor knows it), datePosted (any, 1, 3, 7, 14, 30 days), jobType, remoteOnly, easyApplyOnly, minRating, minSalary, seniority, radius, sort and maxResults. includeDetails (on by default) adds the full description and the company profile. We don't need those here, so we turn it off.

Each posting is a flat record. These are the fields we'll use, with values from the test run below:

Field Example
title, companyName, companyRating Regulatory Data Analyst, Federal Reserve Bank of New York, 3.9
salaryMin, salaryMedian, salaryMax 79300, 82150, 85000
salaryPeriod, salaryCurrency year, USD
salarySource employer (posted by the employer) or glassdoor-estimate
salaryAnnualMin, salaryAnnualMax the range converted to a yearly amount
postedDate 2026-10-21
searchLocation the location Glassdoor actually used: New York, NY (US)

Two of these matter a lot for a fair comparison:

  • salarySource. Some postings carry the employer's own range. For others Glassdoor shows its own estimate (the 10th to 90th percentile for similar jobs). Both are useful, but they are not the same thing, so we count them separately.
  • salaryAnnualMin / salaryAnnualMax. Hourly and monthly ranges are converted to a yearly amount, so a London contract paid by the hour and a Berlin internship paid by the month can sit in the same column.

1. The searches

KEYWORD = "data analyst"
LOCATIONS = ["New York, NY", "London", "Berlin", "Bengaluru"]
COMMON = {"datePosted": "30", "maxResults": 30, "includeDetails": False}
Enter fullscreen mode Exit fullscreen mode

datePosted: "30" keeps the comparison to postings from the last month. 30 jobs per city is one page of Glassdoor results. That's enough to test the script; raise maxResults for a real analysis (see the cost section at the end).

Locations are free text and go through Glassdoor's location search. Check searchLocation in the results (or the run's OUTPUT record) to see what was matched. In our run all four resolved as expected: New York, NY (US), London, England (UK), Berlin (Germany) and Bengaluru (India). For ambiguous names, add the state or country ("Springfield, IL").

2. Run the four searches in parallel

def fetch(location: str) -> list[dict]:
    run = client.actor("atlasdata/glassdoor-jobs-scraper-reliable").call(
        run_input={**COMMON, "keywords": [KEYWORD], "location": location},
        max_total_charge_usd=Decimal("0.05"),
        logger=None,
    )
    if run is None or run.status != "SUCCEEDED":
        raise RuntimeError(f"{location}: run failed: {run and run.status}")
    items = list(client.dataset(run.default_dataset_id).iterate_items())
    for item in items:
        item["market"] = location  # the location we asked for
    return items
Enter fullscreen mode Exit fullscreen mode

One run per location, because location is a single value per run. The runs are independent, so a ThreadPoolExecutor starts all four at once. In the run shown below each one finished in 3 to 7 seconds.

max_total_charge_usd is a hard cap on what a single run can cost. The Actor stops cleanly when it reaches it.

3. Clean the salaries before comparing them

Before looking at medians, look at the rows. Our first run turned up two things that would have skewed the numbers:

  • Internships. Four of the Berlin postings were internships paid 2,470 EUR a month, which becomes 29,640 a year. That's correct, but it isn't a data analyst salary.
  • A typo in a posting. One Berlin employer posted "70 – 90" a year for a senior role. It almost certainly means 70k to 90k, but the data says 70 to 90.

Both are easy to filter:

NOT_COMPARABLE = r"\bintern(?:ship)?\b|werkstudent|working student"

paid = df.dropna(subset=["salaryAnnualMin", "salaryAnnualMax"])
paid = paid[~paid["title"].str.contains(NOT_COMPARABLE, case=False)]  # internships
paid = paid[paid["salaryAnnualMin"] >= 1000]  # "70-90 a year" is a typo for 70k-90k
paid = paid.assign(mid=(paid["salaryAnnualMin"] + paid["salaryAnnualMax"]) / 2)
Enter fullscreen mode Exit fullscreen mode

The \b word boundaries matter: a plain intern would also drop "International" and "Internal Audit" roles. The >= 1000 check is deliberately loose because it has to work for USD, GBP, EUR and INR at the same time.

We use the midpoint of each range as one number per posting. It's a simplification (a 50k–110k range and an 80k range get the same midpoint), so the summary also shows the 25th and 75th percentiles.

4. One row per market

rows.append({
    "market": market,
    "jobs": len(g),
    "salaries": len(p),
    "employer": int((p["salarySource"] == "employer").sum()),
    "currency": p["salaryCurrency"].mode().iat[0] if len(p) else None,
    "p25": p["mid"].quantile(0.25),
    "median": p["mid"].median(),
    "p75": p["mid"].quantile(0.75),
    "rating": g["companyRating"].mean(),
})
Enter fullscreen mode Exit fullscreen mode

salaries is the number of postings that survived the cleaning step, and employer is how many of those were posted by the employer rather than estimated by Glassdoor. Always print these next to the median. A median from 5 postings shouldn't be read the same way as one from 30.

The amounts stay in local currency. If you want one currency, convert with an exchange rate from a date you choose. Keep in mind that salaries in different countries also differ in taxes, benefits and cost of living, so a converted number is still not a like-for-like comparison.

Running it

Real output from our test run (October 2026):

$ python glassdoor_salaries.py
120 jobs saved to glassdoor_jobs.csv

Yearly pay for "data analyst" (midpoint of each range, local currency):
              jobs  salaries  employer currency     p25  median     p75 rating
market
New York, NY    30        30        29      USD  83,809 102,542 117,750    3.7
London          30        28        15      GBP  45,169  48,325  57,547    3.8
Berlin          30         5         5      EUR  70,000  74,500  80,000    3.7
Bengaluru       30        22         0      INR 614,250 686,500 823,625    3.7

Top hiring companies:
  New York, NY: Intuit (2), Capgemini (2), Horizon Media, Inc. (2)
  London: Elexon (2), TELUS Digital (1), Leyland SDM (1)
  Berlin: Institutional Shareholder Services (4), Zalando (3), MMM Consulting GmbH (1)
  Bengaluru: Société Générale (2), Thales (1), Myntra (1)
Enter fullscreen mode Exit fullscreen mode

With only 30 postings per city this is a test of the method, not a salary report. Still, the coverage columns are interesting on their own:

  • New York: 29 of 30 postings had a range posted by the employer. New York requires employers to include pay ranges in job ads, and it shows.
  • London: 28 of 30 had a salary, about half from employers and half Glassdoor estimates.
  • Berlin: only 10 of 30 postings had any salary. After removing the internships and the typo, 5 were left. Don't read anything into that median.
  • Bengaluru: 22 of 30 had a salary, and every one of them was a Glassdoor estimate. That is "what Glassdoor estimates similar jobs pay", not what these employers offered.

"Top hiring companies" is also thin at this sample size: most companies appear once or twice. With a few hundred postings per city it becomes more meaningful, and you can group by companyName to see each company's rating and salary range together.

Full script

"""Compare advertised salaries and top hiring companies across countries with Glassdoor job data."""
import os
from concurrent.futures import ThreadPoolExecutor
from decimal import Decimal

import pandas as pd
from apify_client import ApifyClient

client = ApifyClient(os.environ["APIFY_TOKEN"])

KEYWORD = "data analyst"
LOCATIONS = ["New York, NY", "London", "Berlin", "Bengaluru"]
COMMON = {"datePosted": "30", "maxResults": 30, "includeDetails": False}
NOT_COMPARABLE = r"\bintern(?:ship)?\b|werkstudent|working student"


def fetch(location: str) -> list[dict]:
    run = client.actor("atlasdata/glassdoor-jobs-scraper-reliable").call(
        run_input={**COMMON, "keywords": [KEYWORD], "location": location},
        max_total_charge_usd=Decimal("0.05"),
        logger=None,
    )
    if run is None or run.status != "SUCCEEDED":
        raise RuntimeError(f"{location}: run failed: {run and run.status}")
    items = list(client.dataset(run.default_dataset_id).iterate_items())
    for item in items:
        item["market"] = location  # the location we asked for
    return items


def summarize(df: pd.DataFrame) -> pd.DataFrame:
    paid = df.dropna(subset=["salaryAnnualMin", "salaryAnnualMax"])
    paid = paid[~paid["title"].str.contains(NOT_COMPARABLE, case=False)]  # internships
    paid = paid[paid["salaryAnnualMin"] >= 1000]  # "70-90 a year" is a typo for 70k-90k
    paid = paid.assign(mid=(paid["salaryAnnualMin"] + paid["salaryAnnualMax"]) / 2)

    rows = []
    for market, g in df.groupby("market", sort=False):
        p = paid[paid["market"] == market]
        rows.append({
            "market": market,
            "jobs": len(g),
            "salaries": len(p),
            "employer": int((p["salarySource"] == "employer").sum()),
            "currency": p["salaryCurrency"].mode().iat[0] if len(p) else None,
            "p25": p["mid"].quantile(0.25),
            "median": p["mid"].median(),
            "p75": p["mid"].quantile(0.75),
            "rating": g["companyRating"].mean(),
        })
    return pd.DataFrame(rows).set_index("market")


def main() -> None:
    with ThreadPoolExecutor(max_workers=len(LOCATIONS)) as pool:
        batches = list(pool.map(fetch, LOCATIONS))
    df = pd.DataFrame([job for batch in batches for job in batch])
    df.to_csv("glassdoor_jobs.csv", index=False)
    print(f"{len(df)} jobs saved to glassdoor_jobs.csv\n")

    pd.options.display.float_format = "{:,.0f}".format
    summary = summarize(df)
    summary["rating"] = summary["rating"].round(1).astype(str)
    print(f'Yearly pay for "{KEYWORD}" (midpoint of each range, local currency):')
    print(summary.to_string(), "\n")

    print("Top hiring companies:")
    for market, g in df.groupby("market", sort=False):
        top = g["companyName"].value_counts().head(3)
        print(f"  {market}: " + ", ".join(f"{name} ({n})" for name, n in top.items()))


if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

What it costs

The run above saved 120 jobs in four runs: 120 × $0.0003 plus four run starts at $0.0005 is $0.038. Description and company details are included at the same price if you turn includeDetails back on. For a sturdier comparison, 300 postings per city across the same four cities would be 1,200 jobs, or about $0.36. Duplicates within a run are not charged.

Ideas for extending it

  • Employer-posted only: filter on salarySource == "employer" and compare that median with the Glassdoor estimates for the same market.
  • More roles: put several titles in keywords (each one is its own search) and pivot by searchQuery × market.
  • Company profiles: with includeDetails: True you get companySize, companyRevenue, companyWebsite and companyHeadquarters, which turns "who is hiring" into a lead list.
  • Track it over time: run it weekly with datePosted: "7" and append to the CSV or a database. The Google Jobs tracker post below shows a SQLite pattern for first-seen / last-seen dates.

Glassdoor is one job board. Google Jobs Scraper, our sister Actor, covers the Google Jobs panel, which pulls postings from LinkedIn, company career sites and many other boards. We used it in Build a small job-market tracker with Google Jobs, Python and SQLite.

To ask an AI assistant instead ("what do data analyst jobs in London pay right now?"), the Actor also works as an MCP tool through the hosted Apify MCP server. The client configs for Claude, Cursor and VS Code are in atlas-data-mcp; add atlasdata/glassdoor-jobs-scraper-reliable to the tools= list in the endpoint URL. The Actor's Store page documents every input and output field.

Top comments (0)