How to Scrape IMDb Data: Ratings, Cast, Reviews
Learn how to scrape IMDb data: titles, ratings, cast, and reviews. Pull IMDb's JSON-LD and hidden JSON, then use the official datasets for bulk facts.

Most guides on how to scrape IMDb data hand you a pile of brittle CSS selectors that break the next time IMDb ships a build. There's a better way. Every IMDb title page already carries a clean, machine-readable Movie block in JSON-LD, a full __NEXT_DATA__ payload, and a GraphQL backend behind it, and IMDb publishes official bulk datasets you can download without scraping at all. This guide shows you the stable extraction paths for titles, ratings, cast, and reviews, the exact fields each one gives you, and where the legal line sits so you don't cross it by accident.
What you can scrape from IMDb and the ID system
Before writing any code, understand how IMDb identifies things. Two ID types run through the entire site:
| Entity | ID prefix | Example | URL pattern |
|---|---|---|---|
| Title (film, series, episode) | `tt` | `tt0111161` | `https://www.imdb.com/title/tt0111161/` |
| Name (person) | `nm` | `nm0000151` | `https://www.imdb.com/name/nm0000151/` |
These IDs are called tconst and nconst in IMDb's own data. They're stable, so once you have a title's tconst you can build every URL you need: the main page, /fullcredits, /reviews, /ratings. tt0111161 is The Shawshank Redemption, and it's the working example throughout this guide.
Here's the data you can realistically collect per title, and the best source for each:
| Data | Best source | Why |
|---|---|---|
| Title, year, runtime, genres | JSON-LD or datasets | Present in the initial HTML and the bulk files |
| Aggregate rating and vote count | JSON-LD or `title.ratings.tsv` | One clean field, refreshed daily in datasets |
| Directors, writers, top cast | JSON-LD | `director`, `creator`, `actor` arrays |
| Full cast and characters | `__NEXT_DATA__` / GraphQL / `title.principals.tsv` | JSON-LD only lists the headline cast |
| User reviews | Reviews page / GraphQL | Paginated, dynamic |
| Content rating, release date | JSON-LD | `contentRating`, `datePublished` |
The pattern to internalize: IMDb hands you structured JSON on almost every page. Parsing rendered HTML with selectors is the last resort, not the first.
Is it legal to scrape IMDb?
Start here, because it changes your whole approach. IMDb's Conditions of Use explicitly prohibit "data mining, robots, screen scraping, or similar data gathering and extraction tools" without written permission. That's a contract term, and violating it can get your accounts and IPs blocked and, in some jurisdictions, expose you to a breach-of-contract claim.
Three things are worth separating:
- Public data access. In the United States, courts (notably hiQ Labs v. LinkedIn) have held that scraping publicly available data isn't by itself a Computer Fraud and Abuse Act violation. That is not the same as saying it's contract-safe or copyright-safe.
- Copyright. Factual data (a runtime, a release year, a rating number) generally isn't copyrightable. User review text is. Storing and republishing review bodies carries real copyright exposure that scraping a rating number does not.
- The sanctioned route. IMDb publishes official datasets for exactly this reason. Per IMDb's own documentation, "Subsets of IMDb data are available for access to customers for personal and non-commercial use." If your use is commercial, the free datasets don't cover you, and neither does scraping the site.
Practical takeaway: for bulk facts (titles, ratings, credits), use the official datasets covered in section 8. Scrape live pages only for the fields datasets don't include, do it at a polite rate, cache aggressively, and don't redistribute copyrighted review text. If you're building something commercial, contact IMDb about a licensed feed. None of this is legal advice, and our own guide on ethical scraping and rate limiting goes deeper on staying on the right side of it.
Scraping at scale? Skip the blocks.
Fast, unblockable datacentre proxies with unlimited bandwidth.
The fastest path: IMDb's per-page JSON-LD
Open any IMDb title page, view source, and search for application/ld+json. IMDb embeds a schema.org Movie (or TVSeries) object in the page head. It's server-rendered, so it's in the initial HTML before any JavaScript runs. That one detail saves you money later: you don't need a headless browser to read it.
Here's the shape of the block (numeric values reflect the moment you fetch, since ratings move):
{
"@context": "https://schema.org",
"@type": "Movie",
"url": "/title/tt0111161/",
"name": "The Shawshank Redemption",
"genre": ["Drama"],
"contentRating": "R",
"datePublished": "1994-10-14",
"duration": "PT2H22M",
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": 9.3,
"ratingCount": 2900000,
"bestRating": 10,
"worstRating": 1
},
"director": [{ "@type": "Person", "name": "Frank Darabont", "url": "/name/nm0001104/" }],
"actor": [
{ "@type": "Person", "name": "Tim Robbins", "url": "/name/nm0000209/" },
{ "@type": "Person", "name": "Morgan Freeman", "url": "/name/nm0000151/" }
]
}
The duration is ISO 8601 (PT2H22M means 2 hours 22 minutes), and each actor and director carries a /name/nm... URL you can follow. Parsing it 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 = soup.find("script", {"type": "application/ld+json"})
data = json.loads(block.string)
rating = data.get("aggregateRating") or {}
return {
"type": data.get("@type"), # "Movie", "TVSeries", "TVEpisode"
"title": data.get("name"),
"content_rating": data.get("contentRating"),
"genres": as_list(data.get("genre")),
"runtime_iso": data.get("duration"), # e.g. "PT2H22M"
"release": data.get("datePublished"),
"rating": rating.get("ratingValue"),
"votes": rating.get("ratingCount"),
"directors": [d.get("name") for d in as_list(data.get("director"))],
"cast": [a.get("name") for a in as_list(data.get("actor"))],
}
That handles the headline fields for titles, ratings, and top cast in one shot, no selector maintenance required. The actor array is capped at the page's featured cast, so for a full credit list you'll go one level deeper in section 6.
The JSON-LD also contains a single review object (the top-voted review) with reviewBody, author, and reviewRating. That's the cheapest way to grab one representative review per title without loading the reviews page.
Fetching IMDb reliably with the SparkProxy Scraping API
Reading JSON-LD is easy once you have the HTML. Getting the HTML reliably, at volume, from an anti-bot-aware site is the hard part. IMDb rate-limits by IP and geo-varies its content. The SparkProxy Scraping API takes a target URL and handles the proxy, rotation, and rendering for you. Base URL https://scrape.sparkproxy.io/api/v1, auth via the X-API-Key header.
A basic fetch:
curl "https://scrape.sparkproxy.io/api/v1?url=https://www.imdb.com/title/tt0111161/&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 here
"country_code": "us", # IMDb geo-varies content; pin a region
},
timeout=90,
)
resp.raise_for_status()
return resp.text
html = fetch("https://www.imdb.com/title/tt0111161/")
movie = parse_jsonld(html)
print(movie["title"], movie["rating"], movie["votes"])
Note render_js="false". Because the JSON-LD and __NEXT_DATA__ blocks are baked into the initial HTML, you don't pay for a browser render to read them. On the SparkProxy credit model that's the difference between a 1-credit and a 5-credit request per page, which matters a lot across thousands of titles. Turn render_js on only for the reviews pages that lazy-load content (section 7).
If you'd rather not parse HTML yourself, the API's extract_rules parameter pulls fields with CSS selectors server-side:
import json
params = {
"url": "https://www.imdb.com/title/tt0111161/",
"render_js": "false",
"extract_rules": json.dumps({
"title": "h1",
"genres": {"selector": "div[data-testid='genres'] a", "type": "list"},
}),
}
One caveat, and it's the reason JSON-LD wins: IMDb's data-testid attributes and class names change between builds, so selector-based extraction needs babysitting. The JSON-LD schema is far more stable because IMDb keeps it consistent for Google. Prefer the structured blocks; reach for selectors only for fields that live nowhere else.
Going deeper: __NEXT_DATA__ and the GraphQL backend
IMDb's front end is a Next.js app, which means every page ships a element holding the data the page was rendered from. It's a superset of the JSON-LD: full credit lists, akas, the ratings histogram, box office, keywords, and more. This is the same hidden JSON endpoint trick that works across most modern sites, and IMDb 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"]
page = parse_next_data(html)
# Explore the tree once, then pin the paths you need. Common branches:
# page["aboveTheFoldData"] -> ratings, runtime, genres, primary image
# page["mainColumnData"] -> full cast, reviews summary, akas, keywords
Traverse it once interactively (print the keys) and pin the exact paths your pipeline needs, because IMDb does rename branches between builds. Wrap every lookup in a .get() chain so a renamed key degrades to None instead of crashing your run.
Behind both the page and __NEXT_DATA__ sits IMDb's GraphQL API at https://caching.graphql.imdb.com/. It's what the site itself calls for pagination (loading more cast, more reviews). It's undocumented and uses Apollo persisted queries, so a request looks like this:
curl "https://caching.graphql.imdb.com/" \
-H "content-type: application/json" \
-H "x-imdb-client-name: imdb-web-next" \
--data '{
"operationName": "TitleReviewsPagination",
"variables": { "const": "tt0111161", "first": 25 },
"extensions": { "persistedQuery": { "sha256Hash": "<hash-from-devtools>", "version": 1 } }
}'
The sha256Hash is not something you invent. Open DevTools, watch the Network tab as you click "load more" on a reviews or cast list, and copy the hash and variable shape from the real request. Those hashes rotate when IMDb ships a new build, so treat this as a maintained integration, not a fixed contract. Because it's a raw internal API rather than a rendered page, route it through SparkProxy residential proxies for IP rotation instead of the render pipeline. Our general guide to scraping GraphQL APIs covers replaying persisted queries in more detail. For most projects, JSON-LD plus __NEXT_DATA__ already cover the need, and GraphQL is only worth it when you need deep pagination.
Scraping the full cast and crew
JSON-LD gives you the headline cast, not the full roster. For the complete list you have three options, in order of how much work they are:
__NEXT_DATA__on the main page carries a sizeable cast branch undermainColumnData.- The full credits page at
https://www.imdb.com/title/tt0111161/fullcredits/lists every department (cast, directors, writers, producers, and so on). - The
title.principals.tsvdataset (section 8) gives the principal cast and crew for every title at once, with character names, no scraping.
For a handful of titles, the full credits page is simplest. The character each actor played is the field people forget to grab, and it's right there:
def parse_full_cast(html):
soup = BeautifulSoup(html, "html.parser")
cast = []
for row in soup.select("tr.cast_list_row, li[data-testid='name-credits-list-item']"):
name = row.select_one("a[href*='/name/nm']")
character = row.select_one("[class*='character'], [data-testid='cast-item-characters-link']")
if name:
cast.append({
"name": name.get_text(strip=True),
"nconst": name["href"].split("/name/")[1].split("/")[0],
"character": character.get_text(strip=True) if character else None,
})
return cast
cast = parse_full_cast(fetch("https://www.imdb.com/title/tt0111161/fullcredits/"))
Notice the selectors hedge against two layouts (the classic table and the newer list). That's deliberate: IMDb has been migrating pages, and a scraper that only knows one layout breaks on the other. Pulling the nconst out of each link means you can then fetch each person's /name/nm.../ page for filmography and bio if you need it. For anything beyond a few hundred titles, skip all of this and join the datasets instead.
Scraping IMDb reviews
Reviews are the one place where a headless render earns its cost. The reviews page at https://www.imdb.com/title/tt0111161/reviews/ renders the first batch server-side, then lazy-loads the rest as you scroll or click "All reviews." So this is where you flip render_js on:
reviews_html = fetch("https://www.imdb.com/title/tt0111161/reviews/", render_js="true")
soup = BeautifulSoup(reviews_html, "html.parser")
reviews = []
for card in soup.select("article[data-testid='review-card'], div.review-container"):
summary = card.select_one("[data-testid='review-summary'], a.title")
body = card.select_one("[data-testid='review-overflow'], div.text")
author = card.select_one("a[data-testid='author-link'], span.display-name-link a")
score = card.select_one("span.rating-other-user-rating span, [data-testid='review-rating'] span")
reviews.append({
"summary": summary.get_text(strip=True) if summary else None,
"body": body.get_text(" ", strip=True) if body else None,
"author": author.get_text(strip=True) if author else None,
"rating": score.get_text(strip=True) if score else None,
})
To go past the first page, you have two choices. You can drive the page with the API's js_scenario parameter to click the load-more control and let more cards render, or you can call the GraphQL reviews query from section 5 with its pagination cursor, which is far more efficient at scale. The wait_for parameter is useful here too: point it at a review-card selector so the API waits for content before returning the HTML.
Two reminders on reviews specifically. First, the review text is user-generated and copyrighted by its author, so treat storage and any republication carefully. Second, this is the highest-volume, most rate-limited surface on the site, so pace it. For big review pulls, read how to scrape high-volume data without rate limiting before you turn up the concurrency.
The compliant bulk route: official IMDb datasets
Here's what most scraping tutorials never mention: for the core facts, you don't have to scrape IMDb at all. IMDb publishes gzipped TSV datasets, refreshed daily, at https://datasets.imdbws.com/. Documentation lives at https://developer.imdb.com/non-commercial-datasets/. This is the sanctioned, rate-limit-free way to get titles, ratings, and credits in bulk, subject to that personal and non-commercial license from section 2.
The files:
| File | Key columns | Contents |
|---|---|---|
| `title.basics.tsv.gz` | `tconst`, `titleType`, `primaryTitle`, `startYear`, `runtimeMinutes`, `genres` | One row per title |
| `title.ratings.tsv.gz` | `tconst`, `averageRating`, `numVotes` | Ratings for every rated title |
| `title.principals.tsv.gz` | `tconst`, `nconst`, `category`, `job`, `characters` | Principal cast and crew |
| `name.basics.tsv.gz` | `nconst`, `primaryName`, `birthYear`, `primaryProfession`, `knownForTitles` | One row per person |
| `title.akas.tsv.gz` | `titleId`, `title`, `region`, `language` | Localized titles |
| `title.crew.tsv.gz` | `tconst`, `directors`, `writers` | Director and writer `nconst` lists |
| `title.episode.tsv.gz` | `tconst`, `parentTconst`, `seasonNumber`, `episodeNumber` | Episode-to-series mapping |
Download what you need:
BASE="https://datasets.imdbws.com"
curl -O "$BASE/title.basics.tsv.gz"
curl -O "$BASE/title.ratings.tsv.gz"
curl -O "$BASE/title.principals.tsv.gz"
curl -O "$BASE/name.basics.tsv.gz"
The critical gotcha: these files use a literal \N for null, not an empty string, and they're tab-separated with a header row. Tell your parser both, or every numeric column silently becomes text. In pandas:
import pandas as pd
basics = pd.read_csv("title.basics.tsv.gz", sep="\t", na_values="\\N",
dtype=str, low_memory=False)
ratings = pd.read_csv("title.ratings.tsv.gz", sep="\t", na_values="\\N")
# Top-rated feature films with a meaningful vote count
movies = basics[basics["titleType"] == "movie"].merge(ratings, on="tconst")
movies["numVotes"] = pd.to_numeric(movies["numVotes"])
movies["averageRating"] = pd.to_numeric(movies["averageRating"])
top = (movies[movies["numVotes"] > 50000]
.sort_values("averageRating", ascending=False)
.head(250)[["tconst", "primaryTitle", "startYear", "averageRating", "numVotes"]])
print(top.head())
title.basics.tsv.gz alone is millions of rows, so read with dtype=str and filter early to keep memory sane. Getting a full ratings leaderboard this way takes seconds and touches IMDb's servers exactly once, versus scraping hundreds of thousands of pages.
Cast names need a two-step join, because title.principals stores nconst IDs, not names:
principals = pd.read_csv("title.principals.tsv.gz", sep="\t", na_values="\\N", dtype=str)
names = pd.read_csv("name.basics.tsv.gz", sep="\t", na_values="\\N", dtype=str)
cast = principals[principals["category"].isin(["actor", "actress"])]
cast = cast.merge(names[["nconst", "primaryName"]], on="nconst")
shawshank = (cast[cast["tconst"] == "tt0111161"]
.sort_values("ordering")["primaryName"].tolist())
print(shawshank)
The characters column is a JSON-encoded string like ["Andy Dufresne"], so run it through json.loads when you need the role.
A hybrid pipeline: datasets plus per-title enrichment
The setup that survives contact with real projects combines both worlds: datasets for the bulk skeleton, live scraping only for the fields datasets omit or that need to be fresh (current review text, a today's-rating snapshot, images). Get your candidate list from the datasets, then enrich the ones you care about:
import time
# 1) Bulk: pick your target titles from the datasets (cheap, one download)
target_ids = top["tconst"].head(20).tolist()
# 2) Enrich: scrape only those, reading the stable JSON-LD block
enriched = []
for tconst in target_ids:
html = fetch(f"https://www.imdb.com/title/{tconst}/", render_js="false")
movie = parse_jsonld(html)
movie["tconst"] = tconst
enriched.append(movie)
time.sleep(1) # be polite; let the API rotate IPs between calls
for m in enriched:
print(m["tconst"], m["title"], m["rating"], "->", ", ".join(m["cast"][:3]))
This is the pattern to copy. You pulled 250 titles' worth of ratings from one dataset download, then spent scraping budget on only the 20 you're enriching, using cheap non-rendered requests against the stable JSON-LD. That's an order of magnitude less load on IMDb, less spend for you, and a far lower block rate than crawling every page from scratch.
Anti-bot, rate limits, and reliability
IMDb doesn't run the harshest anti-bot on the web, but it will throttle and geo-gate a single IP that hammers it. A few defaults keep a scraper healthy:
- Pin
country_code. IMDb serves different availability and localized fields by region. Fixingcountry_code=us(or your target market) keeps results consistent run to run. - Keep
render_js=falsewherever you can. The JSON-LD,__NEXT_DATA__, and datasets cover most needs without a browser, so you spend fewer credits and get faster responses. Reserve rendering for review pagination and other lazy-loaded content. - Rotate IPs and back off. Let the API handle rotation, and add exponential backoff on 429 and 503 responses rather than retrying instantly. See our retry and backoff strategies for a drop-in pattern.
- Cache by
tconst. Titles change slowly. Store what you fetched and only re-scrape ratings and reviews on the cadence you actually need, not every run. - Prefer the render pipeline for pages and rotating proxies for internal APIs. Rendered HTML goes through the Scraping API; replayed GraphQL calls go through a rotating residential proxy pool.
Put together, the rule of thumb is simple: reach for the datasets first, the JSON-LD second, the rendered page third, and GraphQL only when you genuinely need pagination. That order minimizes both your cost and your footprint on IMDb.
Frequently asked questions
FAQ
IMDb's Conditions of Use prohibit data mining and screen scraping without permission, so scraping the live site can breach that contract even where accessing public data isn't a hacking-law violation. For lawful bulk access, use IMDb's official datasets, which are licensed for personal and non-commercial use. Treat this as guidance, not legal advice, and get a license for commercial use.
There's no free public REST API for general use. IMDb offers a paid, licensed commercial data service, and separately publishes free non-commercial datasets at datasets.imdbws.com. The GraphQL endpoint at caching.graphql.imdb.com powers the website but is undocumented and unstable, so it isn't an official API you should build a product on.
Download title.ratings.tsv.gz and title.basics.tsv.gz from the official datasets and join them on tconst. You get averageRating and numVotes for every rated title, refreshed daily, without sending a single scraping request. It's faster and far more reliable than an IMDb ratings scraper that crawls pages.
Usually not. The JSON-LD Movie block and the __NEXT_DATA__ payload are in the initial HTML, so you can extract titles, ratings, and top cast with render_js=false and save credits. Only lazy-loaded surfaces like the full reviews list need JavaScript rendering.
The tconst is the tt code in the URL, for example tt0111161 in imdb.com/title/tt0111161/. To find IDs in bulk, match titles against the title.basics.tsv dataset, or scrape IMDb's find/search results and read the tt code out of each result link.
Technically yes, through the reviews page with rendering on or the GraphQL reviews query with its pagination cursor, but it's the most rate-limited part of the site. Pace requests, rotate IPs, and remember review text is copyrighted by its author, so storing and republishing it carries legal risk beyond IMDb's terms.
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 Scrape Redfin Data: Listings, Prices, Market
Learn how to scrape Redfin data: pull listings, prices, and property details from Redfin's Stingray JSON API and CSV export, and handle its rate limits.

How to Set Up and Use a Proxy in Postman
Set up a proxy in Postman the right way: custom proxy host and port, proxy auth, SSL cert fixes, verify the exit IP in the Console, plus Newman env vars.

How to Scrape Yahoo Finance Data (2026 Guide)
Scrape Yahoo Finance quotes, historical prices, and fundamentals from its hidden JSON API. Crumb and cookie setup, 429 fixes, Python code, and the legal rules.
