YouTube Comments Scraper for extracting comment text, authors, like counts, reply counts and timestamps from any YouTube video. This repo has a free YouTube comments web scraping script you can run right now, and a YouTube comments data API that returns 10 fields per comment with cursor pagination.
This is the comments endpoint on its own. The YouTube Scraper repo covers the rest of the surface: search, videos, channels, playlists, transcripts and Shorts.
This repo reads public videos only, and never publishes a comment. Comments are written by members of the public, so no comment body or author display name appears anywhere in this repo: the committed sample is the response envelope with the comments array cut to 0 rows, the field reference is a schema table, and the runnable scripts write rows to a gitignored file instead of your terminal. More on that in Scope.
Last updated: 2026-07-20. Working against YouTube.com as of July 2026, and re-verified whenever YouTube changes their markup.
Below is the YouTube Comments Scraper to get you started. The envelope shown was captured from the live API on 2026-07-20; the cursor token is truncated where marked and the comment rows are cut to 0, but every envelope field is verbatim. The envelope sample is committed in youtube_comments_scraper_api_data/. Every code example calls the actual API and is runnable from youtube_comments_scraper_api_codes/.
pip install requests
export CHOCODATA_API_KEY="your_key" # free: 1,000 requests, one-time, no card
python youtube_comments_scraper_api_codes/comments.pyThose three lines return the envelope below plus 20 comment rows, live from YouTube.com:
{
"video_id": "4czjS9h4Fpg",
"page": 1,
"sort_applied": "top",
"comment_count": null,
"results_count": 20,
"has_more": true
}Each row in the comments array carries 10 fields. This is the shape, not the data, because the data belongs to the people who wrote it:
That is the whole point of this repo. The rest of this page is the reference: the parameters, the real envelope, and the fields worth knowing about before you build on them.
- Scope: public videos, no published comments
- Free YouTube Comments Scraper
- Avoid getting blocked when scraping YouTube comments
- YouTube Comments Scraper API reference
- Page through a video's comments and summarise engagement
- Measured latency
- License
The endpoint returns comments, and a comment is personal data: a display name, an author channel id, and a body written by a member of the public. This repo documents the endpoint without ever publishing one.
Every example and capture here targets a public institutional video: NASA's official Perseverance landing (4czjS9h4Fpg), where the comments are the public reacting to a public brand. Even so:
- The committed sample (
comments_envelope.json) is the response envelope with thecommentsarray cut to 0 of 20 rows. The 8 envelope fields are verbatim; no comment row is committed. - The field reference below is a schema table (field, type, description), not a data dump.
- The runnable scripts print the envelope and per-page aggregates, and write comment rows to a gitignored
comments.jsonl, so a comment body never lands in your scroll-back, a screenshot, or a CI log.
If you point the endpoint at your own audience, the rows are your responsibility. Keep them out of shared logs the same way these scripts do.
YouTube does not put comments in the watch page HTML. The page ships a continuation token instead, and the browser POSTs that token to YouTube's own youtubei/v1/next endpoint to load the first comment page as JSON. You can do the same two steps with requests, no headless browser and no JavaScript rendering. No key, no cost:
python free_scraper/youtube_comments_free_scraper.py "https://www.youtube.com/watch?v=4czjS9h4Fpg"Source: free_scraper/youtube_comments_free_scraper.py. It reads the INNERTUBE_API_KEY, the client version and the comments continuation token out of the watch page's ytInitialData, POSTs the token to youtubei/v1/next, and parses the comment rows. Rows are written to a gitignored comments.jsonl; only counts reach the terminal.
After running the command, your terminal should look something like this:
It works. The two-hop path returned comments on 6 of 6 consecutive attempts in a measured run on 2026-07-20, 4 seconds apart: a ~1.19 MB watch page followed by a ~277 KB next response, 20 comments each. Note that marker strings like captcha and consent.youtube appear in the JavaScript of perfectly healthy watch pages, so the script only calls something a block once the parser finds no comments, never on a substring match.
What actually costs you time on comments is a different set of problems, and it is the subject of the next section.
Getting blocked is not the interesting failure mode here. These are, and all of them are measured against YouTube on 2026-07-20.
First, comments are not in the page you fetch. A watch page is ~1.19 MB of HTML and none of it is a comment. The comments live behind a second request to youtubei/v1/next using a token minted in the first. So the minimum to read page 1 is two hops, and a scraper that only fetches the watch page gets zero comments while looking like it succeeded (HTTP 200, a full 1 MB body).
Second, sort=newest usually cannot be honoured on page 1. The "Newest first" sort needs a sub-menu token that YouTube ships only after the first comment page loads, so it is absent from the initial page. Ask for newest and the response tells you what it actually did: on 4czjS9h4Fpg, sort=newest came back with sort_applied: "top". Read sort_applied, do not assume the sort you asked for is the sort you got.
Third, comment_count is usually null. The header count loads in a later continuation, not the first next response, so comment_count was null on all three videos sampled (4czjS9h4Fpg, dQw4w9WgXcQ, jNQXAC9IVRw). The field exists; it is just empty on page 1 more often than not.
Here is the full picture, and what each item costs you:
| What bites you | Why | What it costs you |
|---|---|---|
| Comments are a second request | The watch page carries a continuation token, not the comments. You POST it to youtubei/v1/next to get them. |
A one-hop scraper returns 200 and zero comments. The failure looks like success. |
sort=newest falls back to top |
The newest-sort token is absent from the initial page. | If you assume newest, you silently analyse top-ranked comments instead. sort_applied is the only truth. |
comment_count is null on page 1 |
The header count arrives in a later continuation. | You cannot read a video's total comment count from page 1. Null on 3 of 3 videos sampled. |
reply_count is null, not 0 |
A comment with no replies carries reply_count_text: "", which parses to null. |
sum(reply_count) throws on null unless you coalesce. 5 of 20 rows on the sample page were null. |
top is the same 20 every call |
Top ordering is stable within a session. | Re-calling with the same params returns identical rows. New comments come from the cursor, not from polling. |
So the two paths, side by side, same video, same day:
Both make the same two hops to reach the comments. The difference is what you carry: a 1.19 MB watch page plus a 277 KB continuation you parse and re-walk yourself, versus one GET that returns the envelope, the 20 parsed rows and the next-page cursor.
The managed option, and the one this repo is built around: the Chocodata YouTube Comments Scraper API. One GET request per comment page, 10 fields per comment of YouTube comment data extraction at scale, a ~99% success rate, and no proxy management. The two-hop fetch and the continuation cursor are handled for you. Free for the first 1,000 requests.
Below is the YouTube Comments Scraper API reference to get you started: authentication, the error bodies, the rate limits, and the endpoint itself.
curl "https://api.chocodata.com/api/v1/youtube/comments?api_key=YOUR_KEY&video_id=4czjS9h4Fpg&sort=top"import requests
r = requests.get(
"https://api.chocodata.com/api/v1/youtube/comments",
params={"api_key": "YOUR_KEY", "video_id": "4czjS9h4Fpg", "sort": "top"},
timeout=90,
)
data = r.json()
print(data["video_id"], data["sort_applied"], data["results_count"], data["has_more"])
# 4czjS9h4Fpg top 20 TrueAfter running the command, your terminal should look something like this. The envelope and aggregates print; the rows go to a gitignored file:
Pass your key as the api_key query parameter. There is no header form and no OAuth step.
https://api.chocodata.com/api/v1/youtube/comments?api_key=YOUR_KEY&video_id=4czjS9h4Fpg
A free key is 1,000 requests, one time, with no card. Get one at chocodata.com.
Nothing below is billed: you are only charged on a 2xx.
| Status | error code |
Meaning | Billed | What to do |
|---|---|---|---|---|
400 |
invalid_params |
None of video_id, url or page_token was supplied. The body names the missing param and its path. |
no | Fix the query string. |
401 |
INVALID_API_KEY |
Key missing, unrecognised, or revoked. | no | Check api_key. Get one at chocodata.com. |
402 |
INSUFFICIENT_CREDITS |
Balance exhausted. | no | Top up or upgrade. |
429 |
RATE_LIMITED |
Over 120 requests/60s, or over your plan's concurrency. | no | Back off and retry. |
502 |
- | YouTube did not return parseable comments, or comments are disabled on this video. | no | Confirm comments are on, then retry once after a few seconds. |
A bad key, verbatim:
curl "https://api.chocodata.com/api/v1/youtube/comments?api_key=totally_invalid_key&video_id=4czjS9h4Fpg"{"error": {"code": "INVALID_API_KEY", "message": "Api key not recognised."}}A missing required param, verbatim. Note that it names the real parameter, which is the fastest way to find out the endpoint takes video_id and not id:
{"error": "invalid_params", "issues": [{"code": "custom", "message": "youtube.comments requires `video_id`, `url`, or `page_token`", "path": ["video_id"]}]}Two response shapes exist: auth and billing errors nest under error.code (uppercase), while param errors are flat with a lowercase error string and an issues array. A video with comments disabled returns 502, not an empty 200. The scripts in this repo map the documented statuses onto actionable messages, so a typo'd key does not hand you a stack trace:
Two separate limits apply, and they are enforced independently.
| Limit | Value |
|---|---|
| Requests per key | 120 per 60 seconds (sliding window) |
| Concurrent requests, Free | 10 |
| Concurrent requests, Vibe | 30 |
| Concurrent requests, Pro | 50 |
| Concurrent requests, Custom | 100 |
Exceed either and you get 429, not a queue. Every call is a synchronous GET: there is no webhook, callback, or async job to poll. Paging one video is sequential because each page_token comes from the previous page. The examples use timeout=90 and keep well under ~1.5 requests per second.
One page of about 20 comments for a video, plus the pagination cursor and, when the page carries it, the total comment count.
| Param | Type | Required | Default | Description |
|---|---|---|---|---|
video_id |
string | one of video_id/url |
- | The 11-char watch id (e.g. 4czjS9h4Fpg), not id. |
url |
string (URL) | one of video_id/url |
- | Any video URL form (watch?v=, youtu.be/, /shorts/, /embed/, /live/); the id is parsed out. |
sort |
enum | no | top |
top or newest. newest needs a token that is usually absent from page 1, so it falls back to top; read sort_applied for what was used. |
country |
string (ISO-2) | no | US |
Two-letter country the request is routed from. |
page_token |
string | no | - | The next_page_token from a previous call. When present, no video_id is needed and exactly one further page is returned, carrying the same sort. |
api_key |
string | yes | - | Your key. Query parameter, not a header. |
curl "https://api.chocodata.com/api/v1/youtube/comments?api_key=YOUR_KEY&video_id=4czjS9h4Fpg"Real response envelope. Every envelope field is present and verbatim except the two cursor fields, truncated where marked; the comments array is cut to 0 of 20 rows, because those rows are written by members of the public and this repo does not publish them (committed sample, same cut):
{
"video_id": "4czjS9h4Fpg",
"url": "https://www.youtube.com/watch?v=4czjS9h4Fpg",
"page": 1,
"sort_applied": "top",
"comment_count": null,
"comment_count_text": null,
"results_count": 20,
"comments": [],
"next_page_token": "eyJjIjoiRWcwU0N6UmplbXBUT1dn...(truncated)",
"next_page_url": "/api/v1/youtube/comments?page_token=eyJjIjoiRW...(truncated)",
"has_more": true
}Each entry in comments carries these 10 fields:
| Field | Type | Description |
|---|---|---|
id |
string | Opaque comment id. Also the pagination anchor for this row. |
text |
string | The comment body. |
author |
string | Author display name. |
author_channel_id |
string | Author channel id. The stable join key for an author, present on all 20 rows sampled. |
author_is_verified |
boolean | Verified badge. 3 of 20 were verified on the page sampled. |
published |
string | Relative age, e.g. "5 years ago" or "5 years ago (edited)". Not a date. |
like_count |
integer | Likes, expanded from the display text. Ranged 7 to 58,000 on the sample page. |
like_count_text |
string | The display form, e.g. "1.2K", "58K". |
reply_count |
integer or null | Replies. null when the comment has no replies, not 0. Null on 5 of 20 rows sampled. |
reply_count_text |
string | The display form; "" when there are no replies. |
text and author are what most people come for, and they are exactly what this repo will not print for you. The comments.py example writes rows to a gitignored comments.jsonl and prints only the envelope and aggregates. Field notes, each measured on 2026-07-20:
sort_appliedis authoritative.sort=newestfell back totopon the sample video. Branch onsort_applied, not on what you requested.comment_countis null on page 1 across all 3 videos sampled. Do not read a video's total from it.reply_countis null for reply-free comments. Coalesce (reply_count or 0) before you sum, or the arithmetic throws.topis deterministic within a session. Three consecutivetopcalls returned the same 20 ids in the same order, so pagination, not re-calling, is how you gather more.- Pagination does not repeat. Page 2 (fetched from
next_page_token) shared 0 ids with page 1.
A page-2 envelope is committed at comments_page2_envelope.json, also cut to 0 rows, showing the slimmer envelope a token page returns (no url, comment_count or comment_count_text).
Runnable: youtube_comments_scraper_api_codes/comments.py
The job most people arrive with: read many pages of a video's comments and get aggregate numbers out, without dumping personal data to a terminal.
export CHOCODATA_API_KEY="your_key"
python youtube_comments_scraper_api_codes/paginate_comments.py 4czjS9h4Fpg --pages 5It follows next_page_token for N pages, appends rows to a gitignored comments.jsonl, and prints only totals: comment count, verified authors, and the summed likes and replies. Five pages of 4czjS9h4Fpg gathered 100 comments with 0 repeats:
Because top is deterministic, a second call with the same params buys nothing; new comments come from the cursor. The script sleeps 1.5s between pages to stay inside the 120 requests/60s limit and retries a lone 502 once after 8 seconds.
Source: youtube_comments_scraper_api_codes/paginate_comments.py
Ten consecutive calls to /youtube/comments for the same video id, 3 seconds apart, on 2026-07-20. All ten returned 200:
| Metric | Value |
|---|---|
| calls attempted | 10 |
200 responses |
10 |
| min | 2,441 ms |
| median | 2,808 ms |
| max | 4,361 ms |
Ten calls is a small sample, which is why the examples set timeout=90 rather than sizing to the median, and why the pagination script retries a 502 once before giving up.
MIT. See LICENSE.







