How to Scrape Goodreads Book Data: Ratings, Reviews
Scrape Goodreads book data after the API shutdown: pull the book-page JSON-LD, Apollo GraphQL reviews, ratings, author pages, and Listopia lists reliably.

Most tutorials on how to scrape Goodreads book data were written for an API that no longer exists. Goodreads stopped issuing new developer keys in December 2020 and wound its public API down, so if you want book ratings, review text, author bibliographies, or Listopia rankings today, scraping the live site is the route that's left. Here's the useful part: every Goodreads book page still ships a clean schema.org Book block, a full Apollo GraphQL cache, and stable /book/show/ URLs you can build a pipeline around. This guide walks the reliable extraction paths for books, ratings, reviews, author pages, and lists, the exact fields each one returns, and where the legal line sits so you don't cross it by accident.
What you can scrape from Goodreads and the ID system
Before writing code, learn how Goodreads names things, because one distinction shapes the whole pipeline: a book is one specific edition (a hardcover, a paperback, an ebook), while a work is the abstract title across all its editions. Ratings and reviews aggregate at the work level, so the review query in section 7 needs a work ID, not the edition ID in the URL.
The URLs themselves are stable and predictable:
| Entity | URL pattern | Example |
|---|---|---|
| Book (edition) | `/book/show/ | `/book/show/2767052-the-hunger-games` |
| Author | `/author/show/ | `/author/show/153394.Suzanne_Collins` |
| Listopia list | `/list/show/ | `/list/show/1.Best_Books_Ever` |
| Genre shelf | `/shelf/show/ | `/shelf/show/fantasy` |
| Series | `/series/ | `/series/73758-the-hunger-games` |
| Search | `/search?q= | `/search?q=hunger+games` |
The numeric prefix is the real ID. The slug after the hyphen is cosmetic, so https://www.goodreads.com/book/show/2767052 loads the same page as the pretty URL. Book 2767052 is The Hunger Games by Suzanne Collins, and it's the working example throughout this guide.
Here's the data you can realistically collect, and the best source for each:
| Data | Best source | Notes |
|---|---|---|
| Title, author, page count, ISBN, format | Book JSON-LD or Open Library | In the initial HTML, and free from Open Library |
| Average rating, rating count, review count | Book JSON-LD `aggregateRating` | `ratingValue`, `ratingCount`, `reviewCount` |
| Star distribution (the 5-star histogram) | `__NEXT_DATA__` / GraphQL | Not present in JSON-LD |
| Review text, reviewer, star score | GraphQL `getReviews` | Paginated, and the text is copyrighted |
| Author bibliography, average rating | Author page | `/author/show/` book links |
| Ranked book lists and vote counts | Listopia | `/list/show/`, server-rendered HTML |
The pattern to internalize: Goodreads hands you structured JSON on the book page. Parsing rendered HTML with CSS selectors is the fallback for the older pages (lists, author, shelves), not the first move for books.
Why scraping is the only route now: the retired API
For years the standard answer to "how do I get Goodreads data" was the official REST API. That answer expired. Goodreads announced it would stop issuing new API keys as of December 8, 2020, and began retiring the public developer API for good. If you're starting a project today, you cannot get a key, existing integrations were told to expect deprecation, and the old endpoints have grown unreliable.
That leaves three practical options, and this guide covers all of them:
- Scrape the live Goodreads pages for the data that is genuinely unique to Goodreads: its large-sample aggregate rating, its review corpus, and its Listopia rankings.
- Pull the plain book facts from open sources (Open Library and the Google Books API) instead of scraping them, which is faster, free, and clearly licensed. See section 10.
- Combine both in a hybrid pipeline so you spend scraping budget only where you have to. See section 11.
One more thing worth knowing up front: Amazon has owned Goodreads since 2013. That matters for two reasons. The Terms of Service inherit Amazon's stance on automated access, and the book identifiers you'll see inside the page carry Amazon work and book IDs (more on that in section 6). If you already scrape the retail side, our guide to scraping Amazon product data shares a lot of the same defensive habits.
Scraping at scale? Skip the blocks.
Fast, unblockable datacentre proxies with unlimited bandwidth.
Is it legal to scrape Goodreads?
Start here, because the answer changes your design. Goodreads' Terms of Service prohibit accessing the site with automated tools or scraping it without permission, mirroring Amazon's broader conditions of use. That's a contract term. Breaking it can get your accounts and IP ranges blocked and, in some jurisdictions, expose you to a breach-of-contract claim.
Three separate ideas are easy to conflate, so keep them apart:
- Public access versus contract. In the United States, courts (notably hiQ Labs v. LinkedIn) have held that scraping publicly available data is not by itself a Computer Fraud and Abuse Act violation. That is not a green light on the site's terms, and it says nothing about copyright.
- Facts versus expression. A rating number, a page count, an ISBN, and a publication date are facts, and facts generally are not copyrightable. A reviewer's written review is creative expression, and it is their copyright. Storing and republishing review bodies carries real exposure that storing a rating number does not.
- Personal data. Reviews come attached to real usernames, avatars, and profile links. Skip that PII unless you have a specific, lawful reason to keep it. Collecting a star score and the review text for aggregate analysis is very different from harvesting a directory of reviewers.
Practical takeaway: get the plain book facts from the open sources in section 10, scrape Goodreads only for its distinctive data, read and respect robots.txt, pace your requests, cache aggressively, and don't redistribute copyrighted review text. None of this is legal advice. Our guide on ethical scraping and rate limiting goes deeper on staying on the right side of the line.
The fastest path: the book-page JSON-LD
Open any Goodreads book page, view source, and search for application/ld+json. Goodreads embeds a schema.org Book object in the page for search engines, and it's server-rendered, so it sits in the initial HTML before any JavaScript runs. That one detail saves you money: you don't need a headless browser to read ratings.
Here's the shape of the block. The numbers are a snapshot from one fetch, since ratings move constantly:
{
"@context": "https://schema.org",
"@type": "Book",
"name": "The Hunger Games",
"image": "https://images.gr-assets.com/books/....jpg",
"bookFormat": "Hardcover",
"numberOfPages": 374,
"inLanguage": "English",
"isbn": "9780439023481",
"author": [
{ "@type": "Person", "name": "Suzanne Collins",
"url": "https://www.goodreads.com/author/show/153394.Suzanne_Collins" }
],
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": 4.34,
"ratingCount": 9400000,
"reviewCount": 235000
}
}
Note the two count fields, because people confuse them constantly. ratingCount is how many people tapped a star score. reviewCount is the much smaller number who wrote text. On a popular title that gap is enormous, often 30 to 1, and the distinction matters if you're computing engagement or sampling reviews. Parsing this is a few lines:
import json
from bs4 import BeautifulSoup
def as_list(value):
if value is None:
return []
return value if isinstance(value, list) else [value]
def parse_jsonld(html):
soup = BeautifulSoup(html, "html.parser")
block = None
for tag in soup.find_all("script", {"type": "application/ld+json"}):
data = json.loads(tag.string)
if data.get("@type") == "Book":
block = data
break
if not block:
return {}
rating = block.get("aggregateRating") or {}
return {
"title": block.get("name"),
"format": block.get("bookFormat"),
"pages": block.get("numberOfPages"),
"language": block.get("inLanguage"),
"isbn": block.get("isbn"),
"authors": [a.get("name") for a in as_list(block.get("author"))],
"rating": rating.get("ratingValue"),
"rating_count": rating.get("ratingCount"),
"review_count": rating.get("reviewCount"),
}
The loop matters: a Goodreads page can carry more than one JSON-LD block (a BreadcrumbList sits alongside the Book), so filter on @type rather than grabbing the first one. This handles the headline fields for title, ratings, and edition metadata in one shot, with no selector maintenance. What it does not give you is the star histogram or the reviews, which live one level deeper.
Fetching Goodreads reliably with the SparkProxy Scraping API
Reading JSON-LD is easy once you have the HTML. Getting that HTML at volume, from an Amazon-owned property that rate-limits by IP, is the hard part. The SparkProxy Scraping API takes a target URL and handles the proxy, rotation, CAPTCHA, and optional rendering. Base URL https://scrape.sparkproxy.io/api/v1, auth through the X-API-Key header.
A basic fetch:
curl "https://scrape.sparkproxy.io/api/v1?url=https://www.goodreads.com/book/show/2767052&render_js=false&country_code=us" \
-H "X-API-Key: YOUR_API_KEY"
The same call in Python, wrapped so the rest of the guide can reuse it:
import requests
def fetch(url, render_js="false"):
resp = requests.get(
"https://scrape.sparkproxy.io/api/v1",
headers={"X-API-Key": "YOUR_API_KEY"},
params={
"url": url,
"render_js": render_js, # JSON-LD is in raw HTML, so keep this off
"country_code": "us", # Goodreads localizes buy links and some fields
},
timeout=90,
)
resp.raise_for_status()
return resp.text
html = fetch("https://www.goodreads.com/book/show/2767052")
book = parse_jsonld(html)
print(book["title"], book["rating"], book["rating_count"])
Watch render_js="false". The parameter defaults to true, and on the SparkProxy credit model a non-rendered request is 1 credit versus 5 for a rendered one. Because the JSON-LD and the __NEXT_DATA__ blob are both baked into the initial HTML, you pay the cheap rate for almost everything and only turn rendering on for the rare page that needs it. Across tens of thousands of books, that is the difference between a sensible bill and a silly one.
If you'd rather not touch HTML at all, the API's extract_rules parameter runs CSS selectors server-side:
import json
params = {
"url": "https://www.goodreads.com/book/show/2767052",
"render_js": "false",
"extract_rules": json.dumps({
"title": "h1[data-testid='bookTitle']",
"rating": "div.RatingStatistics__rating",
}),
}
One caveat, and it's the reason JSON-LD wins on the book page: Goodreads' redesigned pages are a React app whose class names are hashed and rotate between builds, so selector-based extraction needs babysitting. The JSON-LD stays consistent because Goodreads keeps it stable for Google. Prefer the structured block, and reach for selectors only on the older server-rendered pages (lists, shelves, author) where the markup is steadier.
Going deeper: __NEXT_DATA__ and the Apollo GraphQL backend
The modern book page is a Next.js application, which means it ships a element holding the data the page was rendered from. Inside it, props.pageProps.apolloState is a normalized Apollo cache, and it's a superset of the JSON-LD: the full rating histogram, the number of editions, the series, genres, the description, and the first page of reviews all live there. This is the same hidden JSON endpoint idea that works across most modern sites, and Goodreads is a textbook case.
import json
from bs4 import BeautifulSoup
def parse_next_data(html):
soup = BeautifulSoup(html, "html.parser")
tag = soup.find("script", id="__NEXT_DATA__")
data = json.loads(tag.string)
return data["props"]["pageProps"].get("apolloState", {})
state = parse_next_data(html)
# The cache is keyed by type and id. Look for entries like:
# "Book:kca://book/amzn1.gr.book.v1...." -> edition metadata
# "Work:kca://work/amzn1.gr.work.v1...." -> ratings, histogram, reviewsCount
work_key = next(k for k in state if k.startswith("Work:kca://work/"))
work_id = work_key.split("Work:")[1] # the resourceId reviews need
Two things to notice. First, cache keys use a kca:// URI scheme wrapped around Amazon identifiers, for example kca://work/amzn1.gr.work.v1.. That work ID is exactly what the reviews query wants. Second, Goodreads renames branches between builds, so traverse the tree once interactively, pin the paths you need, and wrap every lookup in .get() so a renamed key degrades to None instead of crashing the run.
Behind the page sits the GraphQL backend that the site itself calls for pagination. Goodreads serves it from an AWS AppSync endpoint (a *.appsync-api.us-east-1.amazonaws.com/graphql URL) authenticated with an x-api-key header. A reviews request looks roughly like this:
curl "https://<id>.appsync-api.us-east-1.amazonaws.com/graphql" \
-H "content-type: application/json" \
-H "x-api-key: <key-from-devtools>" \
--data '{
"operationName": "getReviews",
"query": "<query-string-from-devtools>",
"variables": {
"filters": { "resourceType": "WORK", "resourceId": "kca://work/amzn1.gr.work.v1.<hash>" },
"pagination": { "limit": 30, "after": null }
}
}'
You do not invent the endpoint, the key, or the query string. Open DevTools, watch the Network tab as you click through to more reviews, and copy the real request. The AppSync host and x-api-key rotate, so treat this as a maintained integration rather than a fixed contract. Because it's a raw internal API and not a rendered page, route it through rotating SparkProxy residential proxies for IP diversity instead of the render pipeline. Our general guide to scraping GraphQL APIs covers replaying these calls in more detail.
Scraping reviews and handling pagination
Reviews are the payoff and the pain. The getReviews operation returns a page of reviews plus a cursor, so you follow the cursor until it runs out. A default page is around 30 reviews, and the response carries a pageInfo.nextPageToken you feed back in as after:
import requests
APPSYNC = "https://<id>.appsync-api.us-east-1.amazonaws.com/graphql"
HEADERS = {"content-type": "application/json", "x-api-key": "<key-from-devtools>"}
proxies = { # rotate IPs; values come from your SparkProxy dashboard
"http": "http://USER:PASS@gateway.sparkproxy.io:11000",
"https": "http://USER:PASS@gateway.sparkproxy.io:11000",
}
PERSISTED_QUERY = "<getReviews query string copied from DevTools>"
def get_reviews(work_id, pages=5):
out, cursor = [], None
for _ in range(pages):
payload = {
"operationName": "getReviews",
"query": PERSISTED_QUERY,
"variables": {
"filters": {"resourceType": "WORK", "resourceId": work_id},
"pagination": {"limit": 30, "after": cursor},
},
}
r = requests.post(APPSYNC, json=payload, headers=HEADERS,
proxies=proxies, timeout=60)
block = r.json()["data"]["getReviews"]
for edge in block["edges"]:
node = edge["node"]
out.append({
"rating": node.get("rating"),
"text": node.get("text"), # copyrighted; store with care
"created": node.get("createdAt"),
"likes": node.get("likeCount"),
})
cursor = block["pageInfo"].get("nextPageToken")
if not cursor:
break
return out
reviews = get_reviews("kca://work/amzn1.gr.work.v1.<hash>")
The field names (edges, node, pageInfo, nextPageToken) reflect the AppSync schema as seen in DevTools. Verify them against a live response, because Goodreads can adjust the shape. If you'd rather avoid GraphQL entirely for a small job, the first page of reviews is already sitting in the apolloState you parsed in section 6, so you get a free sample without a second request.
Two reminders specific to reviews. The text is user-generated and copyrighted by its author, so keep storage and any republication careful, and default to storing the star score and dropping the reviewer's identity. And this is the highest-volume, most rate-limited surface on the site, so pace it. Before you turn concurrency up for a big pull, read how to scrape high-volume data without rate limiting.
Scraping Listopia lists and shelves
Listopia is Goodreads' community-ranked lists, and it's a goldmine of seed IDs. A list page like /list/show/1.Best_Books_Ever ranks books by member votes, and, like author pages, it's server-rendered, so it scrapes cheaply without a browser. Pagination is a simple ?page=N query parameter.
def parse_list(html):
soup = BeautifulSoup(html, "html.parser")
books = []
for row in soup.select("tr[itemtype*='schema.org/Book']"):
link = row.select_one("a.bookTitle[href*='/book/show/']")
if not link:
continue
href = link["href"]
book_id = href.split("/book/show/")[1].split("-")[0].split(".")[0]
rating = row.select_one("span.minirating")
books.append({
"book_id": book_id,
"title": link.get_text(strip=True),
"rating_text": rating.get_text(strip=True) if rating else None,
})
return books
seed = []
for page in range(1, 4): # first three pages of the list
html = fetch(f"https://www.goodreads.com/list/show/1.Best_Books_Ever?page={page}")
seed.extend(parse_list(html))
print(len(seed), "books collected")
Genre shelves at /shelf/show/, for example /shelf/show/fantasy, follow the same structure and the same ?page=N pagination, so the parser above works with minor selector tweaks. Lists and shelves are the natural front door to a project: pull a ranked set of IDs once from cheap server-rendered pages, then spend your enrichment budget only on the books you actually want.
The compliant bulk route: Open Library and Google Books
Here's the part most Goodreads tutorials skip: for the plain facts about a book, you should not be scraping Goodreads at all. Two open sources give you titles, authors, ISBNs, page counts, publishers, and subjects for free and under clear licenses, which means less load on Goodreads and less legal exposure for you.
| Source | Gives you | Access | Best for |
|---|---|---|---|
| Open Library (Internet Archive) | Title, authors, ISBNs, pages, subjects, covers | REST API plus bulk data dumps | Bulk metadata, catalog joins |
| Google Books API | Title, authors, categories, description, its own average rating | Free REST API (quota) | Descriptions, enrichment |
| Goodreads (scrape) | Large-sample aggregate rating, review corpus, Listopia rankings | Scraping only | Data unique to Goodreads |
Google Books resolves a book by ISBN in one call:
curl "https://www.googleapis.com/books/v1/volumes?q=isbn:9780439023481"
Open Library does the same, and publishes full monthly data dumps at https://openlibrary.org/developers/dumps for people who need every edition and author at once:
import requests
r = requests.get("https://openlibrary.org/isbn/9780439023481.json", timeout=30)
edition = r.json()
print(edition.get("title"), edition.get("number_of_pages"), edition.get("publishers"))
The division of labor is the whole point. Google Books has its own average rating, but it's computed from a tiny sample next to Goodreads' millions of ratings, so the two are not interchangeable. Use Open Library and Google Books for the catalog skeleton, and scrape Goodreads only for the aggregate rating, the review text, and the Listopia rankings that exist nowhere else. This is the same lesson our IMDb scraping guide reaches for movies: prefer the sanctioned bulk source for facts, and scrape live pages only for what it doesn't cover.
A hybrid pipeline, anti-bot, and reliability
Put the pieces together and the pipeline that survives real projects looks like this: seed cheaply, enrich selectively, and reach for the expensive surfaces last.
import time
# 1) Seed: candidate book IDs from a Listopia list (cheap, server-rendered)
target_ids = [b["book_id"] for b in seed[:20]]
# 2) Enrich: read the stable Book JSON-LD for each (render_js off = 1 credit)
catalog = []
for book_id in target_ids:
html = fetch(f"https://www.goodreads.com/book/show/{book_id}", render_js="false")
book = parse_jsonld(html)
book["book_id"] = book_id
catalog.append(book)
time.sleep(1) # be polite; let the pool rotate IPs between calls
# 3) Fill ISBNs and subjects for free from Open Library; pull reviews via
# the GraphQL query only for the handful of titles that need full text.
for m in catalog:
print(m["book_id"], m["title"], m["rating"], f'({m["review_count"]} reviews)')
That flow pulled a ranked set of IDs from one cheap list crawl, enriched only the twenty you care about with 1-credit non-rendered requests against stable JSON-LD, and left the heavy GraphQL review pulls for the titles that truly need them. A few defaults keep it healthy:
- Pin
country_code. Goodreads localizes buy links and some availability by region, so fixingcountry_code=us(or your target market) keeps results consistent run to run. - Keep
render_js=falsewherever you can. JSON-LD,__NEXT_DATA__, lists, shelves, and author pages all resolve without a browser. Reserve rendering for the rare page that genuinely lazy-loads what you need. - Route raw GraphQL through rotating residential IPs, and back off. Add exponential backoff on 429 and 503 responses instead of retrying instantly. Our retry and backoff strategies drop straight in.
- Cache by book ID and work ID. Book metadata changes slowly. Re-scrape ratings and reviews on the cadence you actually need, not every run.
- Respect
robots.txtand pace the review surface. Reviews are the heaviest, most rate-limited part of the site, so crawl them gently.
The rule of thumb: open sources for facts first, the book-page JSON-LD second, __NEXT_DATA__ third, and the GraphQL reviews query only when you need full text at depth. That order keeps both your cost and your footprint on Goodreads low.
Frequently asked questions
FAQ
No. Goodreads stopped issuing new developer API keys as of December 8, 2020, and retired its public API, so you cannot get a key for a new project. For book facts, use the Open Library API or the Google Books API. For Goodreads-specific ratings, reviews, and lists, scraping the live pages is the only remaining route.
Goodreads' Terms of Service, which inherit Amazon's stance, prohibit automated access without permission, so scraping the live site can breach that contract even where accessing public data isn't a hacking-law violation. Rating numbers and ISBNs are facts, but review text is copyrighted by its author, and reviewer profiles are personal data. Pace requests, skip the PII, and don't republish review bodies. This is guidance, not legal advice.
It's the number in the /book/show/ URL, for example 2767052 in goodreads.com/book/show/2767052-the-hunger-games. The slug after the number is optional. To find IDs in bulk, scrape a Listopia list or a genre shelf and read the ID out of each book link, or resolve titles through Goodreads search.
Usually not. The Book JSON-LD and the __NEXT_DATA__ Apollo cache are both server-rendered, so you can pull ratings and metadata with render_js=false and pay 1 credit per page instead of 5. Author pages, Listopia lists, and shelves are classic server-rendered HTML too. Only deep review pagination needs the GraphQL backend, which you call directly rather than by rendering.
Reviews load through the site's AWS AppSync GraphQL endpoint. Call the getReviews operation with the work's resourceId and follow the pageInfo.nextPageToken cursor, roughly 30 reviews per page, until it's empty. Capture the exact endpoint, x-api-key, and query string from DevTools, route the calls through rotating residential IPs, and remember the review text is copyrighted.
A rating is a star score with no text, and a review is a written response that usually includes a star score. The book JSON-LD reports both separately: ratingCount counts everyone who rated, while reviewCount counts the smaller group who wrote text. On popular titles the ratings can outnumber the written reviews by more than 30 to 1.
Get 20% off your first month
Premium datacentre proxies with unlimited bandwidth. Use the code at checkout.
Save up to 15% more on quarterly, half-yearly and yearly plans
Related articles

Proxy Acceptable Use Policies: What Providers Ban and Why
Proxy acceptable use policy explained: the targets, ports and account behaviours providers restrict, how violations are detected, and how to stay unsuspended.

Monthly vs Annual Proxy Plans: When Committing Pays Off
Is an annual proxy plan worth it? Break-even months for 5% to 30% term discounts, the resizing and vendor risks that erase them, and what to ask first.

Scraping API Pricing: How Credit Multipliers Set Real Cost
Scraping API pricing explained: how JS rendering, premium proxies, domain surcharges and billed failures multiply credit costs, with a worked estimate.
