How to Scrape Glassdoor Reviews and Salary Data
Learn how to scrape Glassdoor reviews and salary data: pull ratings, pros, cons, and pay by title, clear the login wall and anti-bot, and stay GDPR-safe.

To scrape Glassdoor you have to accept a hard constraint most tutorials gloss over: the good stuff sits behind a login wall, and the wall is the legal line you do not cross. Glassdoor runs a "give to get" model, so a guest sees a slice of reviews and salaries before a signup modal drops over the rest. The compliant play is to take only what Glassdoor shows the public, and take it cleanly. This guide covers the full pipeline for that public layer: the review, salary, and interview fields worth pulling, how to get a React shell rendered past Cloudflare, how to detect the content wall instead of scraping garbage, how to aggregate pay by job title, and how to handle a dataset that is personal data even when no reviewer is named. Every request runs through the SparkProxy Scraping API, so the anti-bot fight is three parameters instead of a headless-browser farm you keep alive.
Is it legal to scrape Glassdoor?
Settle the framing before you write a line of code, because Glassdoor sits in a trickier legal spot than an open review site.
In the United States, the Ninth Circuit's ruling in hiQ Labs v. LinkedIn (2022) held that scraping data that is genuinely public, meaning no login and no authentication gate, generally does not violate the Computer Fraud and Abuse Act. The key word is public. Glassdoor gates most of its content behind a signup wall, and the Supreme Court's decision in Van Buren v. United States (2021) narrowed the CFAA around "exceeds authorized access," which is exactly the risk you take on the moment you log in and scrape past what a guest can see. So the boundary is clean: the public, pre-wall layer is defensible ground, and creating accounts or replaying authenticated cookies to strip the wall is not. Glassdoor's Terms of Use also prohibit automated collection outright, so even public scraping can be a contract breach separate from any CFAA question.
There is a second layer here that product or price scraping does not have. Reviews and salaries describe real people's employment, pay, and opinions. That makes the dataset personal data under the EU's GDPR even when Glassdoor strips the reviewer's name, and we handle that properly in the GDPR section below.
Practical guardrails that keep a project defensible:
- Check
robots.txtand collect only the public, pre-login layer: overall ratings, the reviews and salary cards Glassdoor renders for guests, and interview summaries. - Do not log in, do not create a "give to get" account to see the rest, and do not buy or replay session cookies. That is the authorization line.
- Rate-limit and back off on errors so you are not degrading Glassdoor's service.
- Do not republish full review text tied to identifiable roles. Aggregate instead.
- If the data feeds a commercial product, run it past a lawyer. This is engineering guidance, not legal advice.
Competitor benchmarking, employer-brand monitoring, and labor-market research on public postings are common, legitimate uses. The business context for that kind of program sits in Using Proxies for Market Research and Data Collection.
What data you can extract (fields reference)
Glassdoor exposes three page types per company, each reachable from the employer's numeric ID (the E1234567 you see in every company URL). Here is what a guest can read on each before the wall, with the fields worth pulling.
Company reviews live at /Reviews/.
| Field | Where it is | Notes |
|---|---|---|
| Overall company rating | Page header | 1.0 to 5.0, one decimal |
| % Recommend to a friend | Page header | Aggregate across all reviews |
| % Approve of CEO | Page header | Aggregate; can be absent for small firms |
| Review rating | Per review card | Integer 1 to 5 |
| Review title | Per review card | The reviewer's headline |
| Pros | Per review card | Free text |
| Cons | Per review card | Free text |
| Advice to Management | Per review card | Optional; often absent in the newer layout |
| Job title | Per review card | For example "Software Engineer" |
| Employment status | Per review card | Current or former employee |
| Employment length | Per review card | For example "more than 3 years" |
| Location | Per review card | City or region, when the reviewer gave one |
| Sub-ratings | Per review card | Work-life balance, culture and values, career opportunities, comp and benefits, senior management, diversity and inclusion |
| Date posted | Per review card | ISO date in the `datetime` attribute |
Salaries live at /Salary/.
| Field | Where it is | Notes |
|---|---|---|
| Job title | Per salary card | The role being reported |
| Median base pay | Per salary card | Annualized, local currency |
| Base pay range | Per salary card | Typically a 10th-to-90th percentile band |
| Additional pay | Per salary card | Cash bonus, stock, commission, profit sharing, tips |
| Total pay | Per salary card | Median plus additional |
| Salaries reported | Per salary card | Sample size behind the number |
| Estimate flag | Per salary card | "Glassdoor Est." (modeled) vs employer-reported |
Interview reviews live at /Interview/.
| Field | Where it is | Notes |
|---|---|---|
| Difficulty | Page header and per card | Easy, average, or hard (and a 1 to 5 score) |
| Experience | Per card | Positive, neutral, or negative |
| Offer status | Per card | Accepted, declined, or no offer |
| Job title | Per card | Role interviewed for |
| Application method | Per card | Applied online, recruiter, referral |
| Process description | Per card | Free text of the interview flow |
| Interview questions | Per card | List of questions the candidate recalled |
One number deserves a caveat before you build charts on it. Glassdoor's headline base pay is often a Glassdoor Estimate, a modeled figure blended from reported salaries and market signals, not raw employee submissions. The "salaries reported" count next to a role tells you the real sample size. Keep the estimate flag as a column so you never mix modeled pay with reported pay in the same aggregate.
Scraping at scale? Skip the blocks.
Fast, unblockable datacentre proxies with unlimited bandwidth.
Why Glassdoor is hard to scrape
Glassdoor is one of the tougher public targets, and for four distinct reasons that each break a naive scraper in a different way.
A React shell, not server-rendered HTML. Glassdoor's initial HTML response is close to an empty container. The reviews, salary cards, and interview data are React components that only exist after JavaScript runs. A plain requests.get returns a page with no data in it, so you need an actual browser to render the content first.
Commercial bot management up front. Glassdoor sits behind Cloudflare's bot layer plus behavioral fingerprinting. It reads the TLS handshake, checks for headless-browser tells, and scores mouse, scroll, and timing signals. A datacenter IP or a stock headless Chrome gets a JavaScript challenge or a 403 before any review loads.
The give-to-get content wall. After a guest views a limited amount, Glassdoor drops a signup modal over the rest of the reviews and salaries. This is not a bug to defeat; it is the boundary from the legal section. You scrape up to it and stop.
Hashed CSS class names. Like most modern React apps, Glassdoor ships CSS-module class names such as ReviewCard_container__x7Yq2 that rehash on frontend deploys. Any parser keyed on those classes returns empty fields the morning after a deploy. The fix is to key on data-test attributes, which are markup hooks Glassdoor keeps far more stable than styling classes.
| Signal | What you will see | How to handle it |
|---|---|---|
| React shell | HTML with no reviews in it | `render_js=true` so a real browser hydrates the page |
| Cloudflare / fingerprinting | 403, JS challenge, or "Verify you are human" | `premium_proxy=true` (residential) plus `stealth=true` |
| Content wall | Reviews stop, a signup modal appears | Detect it, stop that path, do not log in |
| Class-name churn | Selectors return nothing after a deploy | Key on `data-test` attributes, not hashed classes |
A managed scraping API folds the first two problems into parameters. The wall is a hard limit you plan around, and the class churn is a parsing choice you make once. If you want the proxy-side theory behind clearing fingerprinting and challenges, How to Avoid Getting Your Proxy Blocked goes deep on TLS signatures and header consistency.
Set up the SparkProxy Scraping API
The SparkProxy Scraping API takes a target URL and handles the proxy, rotation, rendering, and anti-bot layer for you. You send one request, you get rendered HTML back. Glassdoor needs the fuller configuration because of everything in the last section, and three parameters carry the weight:
render_js=true: run a headless Chromium so the React components mount. Without this you parse an empty shell.premium_proxy=true: route through residential IPs, which clear Glassdoor's Cloudflare where datacenter IPs get challenged.stealth=true: add anti-detection layers for the fingerprinting and JS challenge.
The base endpoint is https://scrape.sparkproxy.io/api/v1 and auth is a single header, X-API-Key. Add country_code=US so you get the .com layout, and a wait_for selector so the API blocks until the review cards actually render instead of returning early. A minimal request in cURL:
curl -G "https://scrape.sparkproxy.io/api/v1" \
-H "X-API-Key: YOUR_API_KEY" \
--data-urlencode "url=https://www.glassdoor.com/Reviews/Example-Corp-Reviews-E1234567.htm" \
--data-urlencode "render_js=true" \
--data-urlencode "premium_proxy=true" \
--data-urlencode "stealth=true" \
--data-urlencode "country_code=US" \
--data-urlencode "wait_for=[data-test='employer-review']"
A rendered premium request like this costs more credits than a plain fetch (roughly 25 for premium plus JS, plus the stealth and country add-ons), which is the real trade for a heavily defended target. Full parameters and response fields are in the Scraping API docs. If you are weighing this against running your own residential pool and headless fleet, Web Scraping API vs Self-Managed Proxies lays out the cost and maintenance trade-off, which tilts hard toward the API on targets this defended.
Wrap the call once so every request carries the Glassdoor configuration:
import requests
API = "https://scrape.sparkproxy.io/api/v1"
API_KEY = "YOUR_API_KEY"
def fetch_glassdoor(url: str, wait_for: str = "[data-test='employer-review']") -> str:
"""Fetch a Glassdoor page through the Scraping API. Glassdoor is a React shell
behind Cloudflare, so render_js, a residential IP, and stealth are all required."""
resp = requests.get(
API,
headers={"X-API-Key": API_KEY},
params={
"url": url,
"render_js": "true", # initial HTML is a shell; hydrate it
"premium_proxy": "true", # residential IPs clear Glassdoor's Cloudflare
"stealth": "true", # extra fingerprint hardening for the JS challenge
"country_code": "US", # get the .com layout
"wait_for": wait_for, # block until the cards mount
},
timeout=90,
)
resp.raise_for_status()
return resp.text
Every page type keys off the company's numeric employer ID. Grab it once from the company overview URL (the E1234567 segment), then build the three URLs from it:
def review_url(slug: str, emp_id: int, page: int = 1) -> str:
base = f"https://www.glassdoor.com/Reviews/{slug}-Reviews-E{emp_id}.htm"
return base if page == 1 else base.replace(".htm", f"_P{page}.htm")
def salary_url(slug: str, emp_id: int) -> str:
return f"https://www.glassdoor.com/Salary/{slug}-Salaries-E{emp_id}.htm"
def interview_url(slug: str, emp_id: int, page: int = 1) -> str:
base = f"https://www.glassdoor.com/Interview/{slug}-Interview-Questions-E{emp_id}.htm"
return base if page == 1 else base.replace(".htm", f"_P{page}.htm")
Here slug is the company name as it appears in the URL, for example Acme-Corp, and emp_id is the number after the E.
Scrape company reviews
Once the page is rendered, the reviews are in the DOM. Parse them with selectolax, a fast C-backed HTML parser (pip install selectolax), and key every field on a data-test attribute rather than a hashed class. Two small helpers keep the parser readable and null-safe:
from selectolax.parser import HTMLParser
def _text(node, sel):
hit = node.css_first(sel)
return hit.text(strip=True) if hit else None
def _attr(node, sel, attr):
hit = node.css_first(sel)
return hit.attributes.get(attr) if hit else None
Now map each review card to a flat record. The data-test values below are current as of mid-2026; Glassdoor renames them occasionally, so confirm them against a live page before a large run and adjust the selectors in one place.
def parse_reviews(html: str) -> list[dict]:
tree = HTMLParser(html)
cards = tree.css("[data-test='employer-review']")
reviews = []
for c in cards:
reviews.append({
"rating": _text(c, "[data-test='review-rating']"),
"title": _text(c, "[data-test='review-title']"),
"pros": _text(c, "[data-test='pros']"),
"cons": _text(c, "[data-test='cons']"),
"job_title": _text(c, "[data-test='reviewer-job-title']"),
"status": _text(c, "[data-test='reviewer-status']"), # current / former
"location": _text(c, "[data-test='reviewer-location']"),
"date": _attr(c, "time", "datetime"), # ISO 8601
})
return reviews
The pros and cons split is what makes Glassdoor reviews more useful than a single free-text field. A reviewer separates what they liked from what they did not, so you get labeled sentiment for free without a classifier guessing which sentence is positive. Keep both columns distinct. For the company-level header, pull the aggregates once per company rather than per card:
def parse_company_summary(html: str) -> dict:
tree = HTMLParser(html)
return {
"overall_rating": _text(tree, "[data-test='rating-value']"),
"recommend_pct": _text(tree, "[data-test='recommend-percent']"),
"ceo_approval_pct": _text(tree, "[data-test='ceo-approve-percent']"),
}
If you would rather have the API extract the fields server-side, the extract_rules parameter maps field names to CSS selectors and returns JSON directly, which saves you the parsing code. Either way, the selector choice is the same: prefer data-test hooks. The downstream analysis (tracking sentiment and rating trends over time) is the same pipeline covered in Using Proxies for Review Monitoring and Sentiment Analysis.
Scrape salary data by job title
The salary page renders one card per job title, each with a median, a range, and a sample size. Parse it the same way, and carry the estimate flag so you never blend modeled pay with reported pay:
import re
def _money(text):
if not text:
return None
digits = re.sub(r"[^\d]", "", text) # strip $ , and /yr text
return int(digits) if digits else None
def parse_salaries(html: str) -> list[dict]:
tree = HTMLParser(html)
cards = tree.css("[data-test='salary-card']")
rows = []
for c in cards:
rows.append({
"job_title": _text(c, "[data-test='salary-title']"),
"base_median": _money(_text(c, "[data-test='base-median']")),
"base_low": _money(_text(c, "[data-test='base-low']")),
"base_high": _money(_text(c, "[data-test='base-high']")),
"additional_pay": _money(_text(c, "[data-test='additional-pay']")),
"reported_count": _money(_text(c, "[data-test='salary-count']")),
"is_estimate": "Glassdoor Est" in (c.text() or ""),
})
return rows
The _money helper matters more than it looks. Glassdoor writes pay as $142K/yr or $142,000, so a naive int() throws, and stripping everything except digits gives you a clean number to aggregate. With the rows in hand, the useful output is pay by title with the sample size attached, which we cover under GDPR because the aggregation choice is also a privacy choice. Compensation benchmarking like this is a core market-research workflow; the collection cadence and storage side is in Using Proxies for Market Research and Data Collection.
Scrape interview reviews
Interview data is the third page type and the one most Glassdoor scrapers skip. It carries difficulty, offer outcome, and the actual questions candidates were asked, which is gold for recruiting research and candidate prep. The card structure mirrors reviews:
def parse_interviews(html: str) -> list[dict]:
tree = HTMLParser(html)
cards = tree.css("[data-test='interview-review']")
rows = []
for c in cards:
questions = [q.text(strip=True) for q in c.css("[data-test='interview-question']")]
rows.append({
"job_title": _text(c, "[data-test='interview-job-title']"),
"difficulty": _text(c, "[data-test='difficulty']"), # easy / average / hard
"experience": _text(c, "[data-test='experience']"), # positive / neutral / negative
"offer_status": _text(c, "[data-test='offer-status']"),
"application": _text(c, "[data-test='application-method']"),
"process": _text(c, "[data-test='interview-process']"),
"questions": questions, # list, may be empty
"date": _attr(c, "time", "datetime"),
})
return rows
Note that questions is a list per card, so store it as JSON or a child table rather than flattening it into one string. The offer-status field lets you segment candidate sentiment by outcome, and the gap between "positive experience, no offer" and "negative experience, accepted offer" is exactly the signal a hiring team wants.
Paginate and handle the login wall
Reviews and interviews paginate with the _P suffix, 10 cards per page. The loop is simple, but it must do one thing every generic scraper forgets: detect the content wall and stop, instead of writing empty rows. A walled page still returns HTTP 200, so you check the body, not the status code.
import time
import random
def is_walled(html: str) -> bool:
"""True when Glassdoor served the signup wall or a bot challenge instead of data."""
markers = (
"Sign in to continue",
"Create your free account",
"Continue with Google", # signup modal
"Verify you are human", # bot challenge
"cf-error-details",
)
return any(m in html for m in markers)
def scrape_reviews(slug: str, emp_id: int, max_pages: int = 10) -> list[dict]:
all_reviews = []
for page in range(1, max_pages + 1):
html = fetch_glassdoor(review_url(slug, emp_id, page))
if is_walled(html):
break # hit the public limit; do not log in
batch = parse_reviews(html)
if not batch:
break # last page reached
all_reviews.extend(batch)
time.sleep(random.uniform(4, 7)) # pace yourself; Glassdoor is strict
return all_reviews
Two habits keep this honest and healthy. First, is_walled treating the signup modal as a stop condition is the technical expression of the legal boundary: when Glassdoor says "log in," you stop, you do not authenticate. Second, the 4-to-7-second pacing is deliberately slower than you would use on a lighter site, because Glassdoor's behavioral scoring punishes fast, evenly spaced requests. The random jitter breaks the metronome pattern that fingerprinting flags.
Scrape at scale without getting blocked
At volume, three habits keep a Glassdoor pipeline alive: retries on soft blocks, exponential backoff so you do not spike the bot layer, and low concurrency. Because the Scraping API rotates the exit IP and renders the page for you, your ceiling is your plan's rate limit rather than a fleet of browsers you babysit. Glassdoor is heavy, so keep parallelism low and spread work across different companies rather than hammering one company's page range.
import time
import random
from concurrent.futures import ThreadPoolExecutor, as_completed
def scrape_company(slug: str, emp_id: int, attempts: int = 3) -> list[dict]:
for i in range(attempts):
try:
reviews = scrape_reviews(slug, emp_id)
if reviews:
return reviews
except Exception:
pass
time.sleep(2 ** i + random.random()) # exponential backoff + jitter
return []
def scrape_many(companies: list[tuple[str, int]], workers: int = 3) -> dict:
out = {}
with ThreadPoolExecutor(max_workers=workers) as pool:
futures = {pool.submit(scrape_company, s, e): s for s, e in companies}
for fut in as_completed(futures):
out[futures[fut]] = fut.result()
return out
Three workers is plenty for a defended target like this; going wider raises your block rate faster than it raises throughput. The jitter staggers retries so a batch of failures does not re-fire in lockstep and re-trigger the same challenge. If you find yourself managing browser pools, CAPTCHA solvers, and residential rotation by hand, that is the exact overhead the API removes, and the self-managed comparison quantifies where the line falls.
GDPR and employee data
This is where Glassdoor differs from every other review target, and where the copy-paste advice from Trustpilot guides gets it wrong. Glassdoor already strips the reviewer's name, so there is no displayName to hash. That does not put you outside the GDPR. Under Recital 26, data is personal when a person can be singled out, and a review that reads "Senior Recruiter, former employee, more than 4 years, Berlin office" can identify exactly one human at a 30-person company even with no name attached. Job title plus tenure plus location plus employment status is a fingerprint.
So the privacy work on Glassdoor is about re-identification, not name removal. Practical handling that keeps the dataset defensible:
- Aggregate, do not store raw identifiable rows. For salaries, group by title and suppress any group with too few data points, so you never publish a figure that points at one person. This is k-anonymity in three lines:
import pandas as pd
def aggregate_salaries(rows: list[dict], k: int = 5) -> pd.DataFrame:
df = pd.DataFrame(rows)
df["base_median"] = pd.to_numeric(df["base_median"], errors="coerce")
grouped = (
df.groupby("job_title")
.agg(n=("base_median", "size"), median_base=("base_median", "median"))
.reset_index()
)
return grouped[grouped["n"] >= k] # drop titles too small to anonymize
- Drop the quasi-identifiers you do not need. If the analysis is company-level sentiment, you do not need location and exact tenure on every row. Coarsen tenure into buckets and drop precise location.
- Have a lawful basis. For competitive or labor-market research, "legitimate interests" under GDPR Article 6(1)(f) is the usual basis, and it requires a documented balancing test weighing your interest against the employee's privacy.
- Do not republish identifiable reviews. Aggregate ratings, pay bands with a healthy sample size, and anonymized themes are far safer to surface than a searchable copy of "the one senior recruiter in Berlin."
- Honor deletion. Key rows on a synthetic ID so you can drop a record if someone objects.
None of this blocks the analysis anyone actually wants. It shapes what you keep. A pipeline that stores aggregated ratings, bucketed sentiment, and pay bands with a minimum sample size answers almost every business question while holding almost no data that singles out an individual.
Frequently asked questions
FAQ
Scraping the genuinely public, pre-login layer generally does not violate the US Computer Fraud and Abuse Act under hiQ v. LinkedIn (9th Cir. 2022), but logging in to reach gated content raises the "exceeds authorized access" risk the Supreme Court flagged in Van Buren (2021), and Glassdoor's Terms separately prohibit automated collection. Reviews and salaries are also personal data under GDPR. Stay on the public layer, do not authenticate, minimize personal data, and get legal advice before commercial use.
Yes, and you should stay logged out. Glassdoor shows guests a limited slice of reviews and salaries before a "give to get" signup wall covers the rest. Scraping that public slice is the defensible approach. Detect the wall in your code and stop when you hit it rather than creating an account or replaying session cookies to bypass it, since that authentication step is the legal red line.
Yes. Glassdoor's initial HTML is a near-empty React shell, so the reviews and salary cards only exist after JavaScript runs. Send render_js=true so a headless Chromium hydrates the page, and pair it with premium_proxy=true and stealth=true to clear Glassdoor's Cloudflare and behavioral fingerprinting. A wait_for selector makes the API block until the cards actually render.
Request the /Salary/ page with rendering on, then parse each salary card for the title, median base pay, pay range, additional pay, and the "salaries reported" count. Strip currency symbols before converting to numbers, keep the "Glassdoor Est." flag so you do not blend modeled pay with reported pay, and group by title with a minimum sample size before you publish any figure.
Yes. GDPR Recital 26 covers data that can single out a person, and a review carrying job title, tenure, location, and current-or-former status can identify one individual at a small employer even with no name. Treat re-identification as the risk: aggregate salaries with k-anonymity, drop quasi-identifiers you do not need, document a lawful basis such as legitimate interests, and be able to delete records on request.
Not a usable one for arbitrary companies. Glassdoor's public API was restricted years ago and is effectively closed to new third-party developers, so there is no clean first-party route to pull competitor reviews or salaries the way you can with some other platforms. Public scraping within the legal and privacy guardrails above is the practical option, which is why getting the anti-bot and consent handling right matters.
Get 50% off your first purchase
Premium datacentre proxies with unlimited bandwidth. Use the code at checkout.
Offer ends soon — claim it before it's gone
Related articles

How to Handle Cookies and Sessions When Web Scraping
Handle cookies and sessions when web scraping: cookie jars, Set-Cookie parsing, CSRF tokens, login state, disk persistence, and sticky proxy IPs, in Python.

Async Web Scraping in Python: A Concurrency Guide
Master async web scraping in Python with asyncio, httpx and aiohttp: bound concurrency with semaphores, size connection pools, and isolate failures.

How to Scrape Google News: RSS, Headlines, and Feeds
Scrape Google News with its free RSS feeds: pull headlines and articles, decode the redirected article links, and geo-target editions with hl, gl, and ceid.
