"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:
- runs the same Glassdoor search in four cities in four countries, in parallel,
- saves every posting to a CSV,
- 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
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}
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
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)
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(),
})
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)
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()
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 bysearchQuery×market. -
Company profiles: with
includeDetails: Trueyou getcompanySize,companyRevenue,companyWebsiteandcompanyHeadquarters, 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)