🎉 Premium Proxies · 3-Day Free TrialClaim Now →
Guides

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.

S SparkProxy 2 22 min read
Share
How to Scrape Glassdoor Reviews and Salary Data

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.

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/-Reviews-E.htm.

FieldWhere it isNotes
Overall company ratingPage header1.0 to 5.0, one decimal
% Recommend to a friendPage headerAggregate across all reviews
% Approve of CEOPage headerAggregate; can be absent for small firms
Review ratingPer review cardInteger 1 to 5
Review titlePer review cardThe reviewer's headline
ProsPer review cardFree text
ConsPer review cardFree text
Advice to ManagementPer review cardOptional; often absent in the newer layout
Job titlePer review cardFor example "Software Engineer"
Employment statusPer review cardCurrent or former employee
Employment lengthPer review cardFor example "more than 3 years"
LocationPer review cardCity or region, when the reviewer gave one
Sub-ratingsPer review cardWork-life balance, culture and values, career opportunities, comp and benefits, senior management, diversity and inclusion
Date postedPer review cardISO date in the `datetime` attribute

Salaries live at /Salary/-Salaries-E.htm.

FieldWhere it isNotes
Job titlePer salary cardThe role being reported
Median base payPer salary cardAnnualized, local currency
Base pay rangePer salary cardTypically a 10th-to-90th percentile band
Additional payPer salary cardCash bonus, stock, commission, profit sharing, tips
Total payPer salary cardMedian plus additional
Salaries reportedPer salary cardSample size behind the number
Estimate flagPer salary card"Glassdoor Est." (modeled) vs employer-reported

Interview reviews live at /Interview/-Interview-Questions-E.htm.

FieldWhere it isNotes
DifficultyPage header and per cardEasy, average, or hard (and a 1 to 5 score)
ExperiencePer cardPositive, neutral, or negative
Offer statusPer cardAccepted, declined, or no offer
Job titlePer cardRole interviewed for
Application methodPer cardApplied online, recruiter, referral
Process descriptionPer cardFree text of the interview flow
Interview questionsPer cardList 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.

Free trial

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.

SignalWhat you will seeHow to handle it
React shellHTML with no reviews in it`render_js=true` so a real browser hydrates the page
Cloudflare / fingerprinting403, JS challenge, or "Verify you are human"`premium_proxy=true` (residential) plus `stealth=true`
Content wallReviews stop, a signup modal appearsDetect it, stop that path, do not log in
Class-name churnSelectors return nothing after a deployKey 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.htm 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/-Salaries-E.htm 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.

Limited-time · 50% off

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

Claim Discount

About the Author

SparkProxy Technical Team. The SparkProxy engineering team builds and maintains global datacenter and residential proxy infrastructure plus a managed Scraping API. This guide reflects patterns tested against glassdoor.com in 2026 using Python 3.11+, requests 2.32+, and selectolax 0.3+. Glassdoor changes its frontend, data-test hooks, and anti-bot posture often, so treat the selectors as current-as-of and confirm them against a live page before a large run.

Citations: hiQ Labs v. LinkedIn, 9th Cir. 2022 · Van Buren v. United States, 2021 · GDPR Recital 26 · SparkProxy Scraping API docs

Keep reading

Related articles