Salary Data from Job Sites: Reading Ranges Without Bias
Salary data from job sites is usable if you treat it as a probably self-reported distribution, not a measurement. Read the percentile range, not a single average. Check the currency and pay period. Check how many reports sit behind the number. Then compare it with a source that doesn't rely on volunteers. This article walks through a recorded Glassdoor salaries response and lists the biases to correct for.
What does the recorded salaries response contain?
A call to GET /v1/glassdoor/salaries?job_title=software+engineer returns a list of employer rows and one national estimate block. Each employer row carries currency, pay_period, base_pay_mean, additional_pay_mean and a five-point total_pay distribution. The estimate adds location, confidence and salaries_count.
Here is the shape, trimmed from the recording:
{
"success": true,
"data": {
"job_title": "software engineer",
"salaries": [
{
"job_title": "Software Engineer",
"employer": { "id": 9079, "name": "Google", "rating": 4.4 },
"currency": "USD",
"pay_period": "ANNUAL",
"base_pay_mean": 184722.83,
"additional_pay_mean": 126972.82,
"total_pay": { "p10": 207909.08, "p50": 311695.65, "p90": 486503.67 }
}
],
"total_results": 104377,
"estimate": {
"currency": "USD",
"pay_period": "ANNUAL",
"location": "United States",
"confidence": "CONFIDENT",
"salaries_count": 727155,
"total_pay": { "p10": 99464.84, "p50": 152101.59, "p90": 237195.82 }
}
}
}
The endpoint takes job_title (required), plus optional employer_id, page and limit. See the Glassdoor salaries endpoint for the full page, or the earlier Glassdoor API walkthrough for reviews and salaries together.
How wide are the ranges, and what do they say?
The recorded p90 is roughly 2.0 to 2.4 times the p10 in every row we have. A range that wide is the honest answer to "what does this job pay", and a single midpoint hides it. Compare spread and level separately.
| Row | p10 | p50 | p90 | p90 / p10 |
|---|---|---|---|---|
| Google, total pay | 207,909.08 | 311,695.65 | 486,503.67 | 2.34 |
| Microsoft, total pay | 153,947.14 | 218,040.69 | 318,811.92 | 2.07 |
| US estimate, total pay | 99,464.84 | 152,101.59 | 237,195.82 | 2.38 |
| US estimate, base pay | 79,463.04 | 117,615.72 | 174,086.68 | 2.19 |
All values are USD and ANNUAL. Two readings follow. First, the gap between US total pay and base pay at the median is 34,485.87 (152,101.59 minus 117,615.72), so bonus and stock are a real share of what people report. Second, an employer row doesn't look like a slice of the national row. Google's p10 is above the national p75, and the response doesn't say how the two relate, so don't compare them directly.
One more detail worth checking in your own data: for both employers, base_pay_mean plus additional_pay_mean equals the total_pay.p50 exactly (184,722.83 + 126,972.82 = 311,695.65 for Google). The response doesn't say how the median is derived, so verify this on the rows you use before treating the p50 as an independent observation.
Which fields tell you how much to trust a number?
Trust depends on sample size, and the response spreads it across endpoints. The employer rows have no count, the estimate has salaries_count and a confidence label, and the company search has salary_count per employer. Pull the count before you quote a range.
| Field | Where | Recorded value | Use it for |
|---|---|---|---|
estimate.salaries_count |
/v1/glassdoor/salaries |
727,155 | Size of the national pool for the title |
estimate.confidence |
/v1/glassdoor/salaries |
CONFIDENT |
A label to filter on; we have no documented rule for it |
total_results |
/v1/glassdoor/salaries |
104,377 | Employer rows for the title, not reports |
salary_count |
/v1/glassdoor/companies-search |
4,702 (Stripe), 38 (Stripe OLT) | A company-level count; it sits on the company record, not on a title |
The last row is the trap. salary_count appears on the company record, with no job title attached, so it looks company-wide: 4,702 at Stripe probably doesn't mean 4,702 software engineer salaries. The field isn't documented, so check it against your own pulls. Stripe OLT, with 38, shouldn't get a published percentile range at all. Look up an employer_id with the companies search endpoint, then pass it to salaries.
Also watch units. Every recorded row says USD and ANNUAL, but the fields exist because other values can occur. Reject any row whose currency or pay_period you didn't expect rather than converting it silently.
What biases does self-reported pay carry?
Self-reported pay is skewed by who reports, what they report and when. The response doesn't document the inputs behind any one number, so we can't say how much of it is user-submitted. What the response does give is a count of the salaries behind the national figure (salaries_count), so treat it as a distribution of individual reports, not a payroll census. Treat the list below as a checklist, not a footnote. None of it can be removed from the JSON; you correct for it by comparison.
The middle column says whether our two-employer recording shows anything. The rest are general risks of self-reported sources that we haven't measured here.
| Bias | Evidence in the recording | What to do |
|---|---|---|
| Total-pay definitions | Shown: additional_pay_mean is 126,972.82 for Google and 56,847.98 for Microsoft, so bonus and stock vary a lot by employer |
Compare base and total separately |
| Seniority blur | Consistent with it: p90/p10 is 2.07 to 2.38 for one title | Filter by employer and title, check the spread |
| Location mixing | Shown: the estimate says United States, so a city premium is flattened |
Don't apply a national range to one city |
| Self-selection | General risk; not testable from the JSON | Compare with an employer-reported source |
| Rounding and recall | General risk; not testable from the JSON | Use ranges, not exact values |
| Employer mix | General risk; the recording has only two employer rows, so it can't show who dominates | Don't read the employer list as the market |
| Staleness | General risk; the rows carry no report dates | Re-pull on a schedule and diff the percentiles |
| Selective sharing | General risk; nothing in the response says whether reports are verified | Cross-check outliers before using them |
The additional_pay_mean gap tests the definition problem. The wide p90/p10 ratio fits seniority blur: if one title were one job, you'd expect a narrower range, though the ratio alone doesn't prove it. The table doesn't prove any of the biases for this sample; it tells you where to look.
What should you compare it with?
Compare job-site pay with a survey that employers report to. The US Bureau of Labor Statistics builds its OEWS estimates from employer responses: a sample of about 1.1 million establishments collected over three years (BLS, Occupational Employment and Wage Statistics, 2025). That is a different method with its own limits, such as a May reference date and a lag, but it doesn't depend on volunteers.
The practical rule: use job-site data for the shape and the employer-level detail, and use a survey for the level. If your Glassdoor national p50 for a title sits far from the OEWS median for the closest occupation, suspect definitions first (total versus base pay) and mix second. For official methodology and tables, see the BLS OEWS program.
Can Indeed fill the gap?
Not from the recording we have. The Indeed jobs endpoint takes query, location, country, job_type and more, and returns postings. The recorded job objects carry id, title, company, location, description_html, attributes, date_published and apply_url. None of those is a structured salary field.
So treat Indeed as a way to count and describe postings, not to read pay. If a posting states a range, it would be in the text, and you'd have to extract it yourself and handle currency, period and wording. A posted range is also an employer's statement of intent, a different thing from what employees report earning. See Indeed Jobs API for the fields and job posting data for labour-market research for what postings can show.
How do you pull and sanity-check ranges in code?
Pull the rows, assert the units, compute the spread, and refuse to report ranges without a count. This script runs as written once API_KEY is set, and costs 1 credit per call.
import os
import requests
BASE = "https://api.perceptlayer.com"
HEADERS = {"X-API-Key": os.environ["API_KEY"]}
def salary_ranges(job_title, employer_id=None):
params = {"job_title": job_title}
if employer_id:
params["employer_id"] = employer_id
r = requests.get(f"{BASE}/v1/glassdoor/salaries", headers=HEADERS, params=params, timeout=30)
r.raise_for_status()
data = r.json()["data"]
est = data.get("estimate") or {}
print("estimate:", est.get("location"), est.get("confidence"), est.get("salaries_count"))
for row in data["salaries"]:
if row["currency"] != "USD" or row["pay_period"] != "ANNUAL":
print("skip unexpected units:", row["employer"]["name"])
continue
pay = row["total_pay"]
print(
row["employer"]["name"],
round(pay["p10"]), round(pay["p50"]), round(pay["p90"]),
"spread", round(pay["p90"] / pay["p10"], 2),
)
salary_ranges("software engineer")
Add a second call to /v1/glassdoor/companies-search for salary_count on any employer you intend to publish. A run over 100 job titles, one page each, costs 100 × 1 credit = 100 credits, well inside the 1,000 free credits a month. Walking all 10,438 pages for one title would cost 10,438 credits, which is rarely worth it; the first page plus the estimate block is usually enough. Authentication is covered in the authentication docs, and credits explains billing. For what credits cost, see pricing.
Data is collected from publicly available sources and returned as found at the time of the call. Re-run the same query later and the percentiles can move.
To try it on your own job titles, get a free API key and run the salaries call in the playground on the Glassdoor API page. More jobs data lives under Jobs APIs.
APIs in this post
Frequently asked questions
How accurate is salary data from job sites?
It is accurate as a description of what was reported, and unverified as a description of what everyone earns. Percentile ranges are more reliable than means. Check the report count, the currency and pay period, and compare the level with an employer-reported survey such as the BLS OEWS before relying on it.
Does the Glassdoor salaries endpoint return a sample size?
Not on the employer rows. The national estimate block returns salaries_count (727,155 in our recording), and /v1/glassdoor/companies-search returns a company-level salary_count. The employer row for a title has no count, so don't publish thin ranges without checking the company-level figure.
Can I get salary data from Indeed?
The recorded response from /v1/indeed/jobs has no structured salary field. Its fields are the job id, title, company, location, description_html, attributes, date_published and apply_url. Any pay stated in a posting would have to be extracted from the text yourself, and it reflects what the employer offers.
How much does it cost to pull salary ranges?
Each call to /v1/glassdoor/salaries costs 1 credit, and failed calls cost nothing. Looking up 100 job titles at one page each is 100 × 1 credit = 100 credits. Every account gets 1,000 free credits a month after verifying an email address, with no credit card.