Skip to content

ChocoData-com/youtube-playlist-scraper

Repository files navigation

YouTube Playlist Scraper

YouTube Playlist Scraper

YouTube Playlist Scraper for extracting playlist video ids, titles, channels, views and publish dates from YouTube.com. This repo has a free YouTube playlist web scraping script you can run right now, and a YouTube playlist data API that returns the playlist header plus one structured row per video.

This is the playlist endpoint on its own. The YouTube Scraper repo covers the rest of the surface: search, videos, channels, transcripts, comments and Shorts.

Last updated: 2026-07-20. Working against YouTube.com as of July 2026, and re-verified whenever YouTube changes their markup.

Every JSON block on this page was captured from the live API on 2026-07-20. Long arrays are trimmed and each block says exactly what was cut; the fields shown are verbatim. Full uncut samples are committed in youtube_playlist_scraper_api_data/. Every code example calls the actual API and is runnable from youtube_playlist_scraper_api_codes/.

pip install requests
export CHOCODATA_API_KEY="your_key"     # free: 1,000 requests, one-time, no card
python youtube_playlist_scraper_api_codes/playlist.py

Those three lines return this, live from YouTube.com:

{
  "playlist_id": "PLZHQObOWTQDMsr9K-rj53DwVRMYO3t5Yr",
  "title": "Essence of calculus",
  "channel": "3Blue1Brown",
  "video_count": 12,
  "views": null,
  "videos_returned": 12,
  "videos": [
    {
      "position": 1,
      "id": "WUvTyaaNkzM",
      "title": "The essence of calculus",
      "url": "https://www.youtube.com/watch?v=WUvTyaaNkzM",
      "thumbnail": "https://i.ytimg.com/vi/WUvTyaaNkzM/hq720.jpg?sqp=-oaymwEcCK4FEIIDSEbyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLB_uI7Bw3WReKIm5DnBUeFDsrEt7w",
      "channel": "3Blue1Brown",
      "views": "11M views",
      "published": "9 years ago"
    }
  ]
}

That is videos cut to 1 row. Multiplied out, it is the whole playlist with 8 fields per video:

Retrieved YouTube playlist data

That is the whole point of this repo. The rest of this page is the reference: the parameters, the real response, and the two header fields that mean different things than their names suggest.


Contents


Free YouTube Playlist Scraper

YouTube server-renders the playlist page into a JavaScript object called ytInitialData, so you can extract the video list without a headless browser or JavaScript rendering. No key, no cost:

python free_scraper/youtube_playlist_free_scraper.py "https://www.youtube.com/playlist?list=PLZHQObOWTQDMsr9K-rj53DwVRMYO3t5Yr"

Source: free_scraper/youtube_playlist_free_scraper.py. It finds ytInitialData, brace-counts to its matching close, walks the tree for lockupViewModel nodes and emits position, id, title, url per video.

After running the command, your terminal should look something like this:

Free YouTube playlist scraper parsing a real playlist

It works, once you know one thing. With that one thing in place, 8 of 8 consecutive attempts returned all 12 videos in a measured run on 2026-07-20, 4 seconds apart, at 903,019 to 922,517 bytes each.

Without that one thing, all 6 of the cold attempts measured below returned zero rows. It is the subject of the next section.

Avoid getting blocked when scraping YouTube playlists

The playlist route does not refuse a plain client. It answers one, with an interstitial, and the response looks enough like success to fool a scraper that is not checking.

A cold requests.get to /playlist?list=..., six different playlist ids, all on 2026-07-20:

The YouTube consent interstitial, measured

What we measured (plain fetch, /playlist?list=...) Value
HTTP status 200 (not 403, not 429)
Response size 584,204 to 584,359 bytes, a 155-byte spread across 6 different ids
<title> Before you continue to YouTube
ytInitialData blob absent (0 occurrences)
Playlist rows found 0, on all 6

Six different playlists coming back within 155 bytes of each other is the tell. That is one consent interstitial served six times, not six playlists. Nothing throws, nothing warns, and a scraper that logs "0 results" will keep logging it forever.

The fix is one cookie, and it is worth knowing rather than working around: YouTube's own consent screen sets SOCS, and sending SOCS=CAI gets the real page. The free scraper in this repo sets it, which is why the run above works. The same request then returns the real page: 908,081 bytes on the run screenshotted above, with ytInitialData present and the full video list inside.

Two more things about this route, both measured:

The watch page and the playlist page do not behave the same way. The same client, same session, got a full watch page with no cookie at all, and the consent interstitial on /playlist. Testing one route tells you nothing about the other.

The rows are lockupViewModel, not playlistVideoRenderer. The playlist pages fetched for this README contained zero playlistVideoRenderer nodes, which is the renderer most older examples parse. A parser looking only for that node returns an empty list against a page that is full of videos.

What bites you Why What it costs you
HTTP 200 is not success The consent interstitial returns 200 with a full-looking page. A naive scraper parses it, finds nothing, and logs "0 results" rather than "blocked". The expensive failure is not a crash. It is weeks of empty data that looked like an empty playlist.
Consent gating is per route /watch served a cold client fine on the same day /playlist did not. A block test on one URL pattern does not generalise. Say which URL, or the claim is unfalsifiable.
The renderer moved Rows arrived as lockupViewModel on every playlist page fetched here; the legacy playlistVideoRenderer was absent from all of them. A parser written only against the older node silently returns [].
A playlist page is ~900 KB The video list is embedded in a page built to render a playlist UI. Bandwidth and parse time scale with the page, not with the rows you keep.
Header metadata is not always there Both channel uploads playlists sampled (UU… ids) came back with no page header, so there was no title and no count to read. Fields you assumed were present are absent for a whole class of playlist id.

So the two paths, side by side, same playlist, same day:

Free YouTube playlist scraper vs Chocodata API, measured

Both list the playlist. The difference is what each row carries, and whether you have to know about the consent cookie and the renderer name before you get anything at all.


Using the Chocodata YouTube Playlist Scraper API

The managed option, and the one this repo is built around: the Chocodata YouTube Playlist Scraper API. One GET request per playlist URL, the header plus 8 fields per video for YouTube playlist data extraction at scale, a ~99% success rate, and no consent or proxy management. Rows are collected from both renderer shapes YouTube uses for playlist items (lockupViewModel and the legacy playlistVideoRenderer) and normalised into one row shape. Free for the first 1,000 requests.


YouTube Playlist Scraper API reference

Below is the YouTube Playlist Scraper API reference to get you started: authentication, the error bodies, the rate limits, and the endpoint itself.

Quickstart

curl "https://api.chocodata.com/api/v1/youtube/playlist?api_key=YOUR_KEY&url=https://www.youtube.com/playlist?list=PLZHQObOWTQDMsr9K-rj53DwVRMYO3t5Yr"
import requests

r = requests.get(
    "https://api.chocodata.com/api/v1/youtube/playlist",
    params={"api_key": "YOUR_KEY",
            "url": "https://www.youtube.com/playlist?list=PLZHQObOWTQDMsr9K-rj53DwVRMYO3t5Yr"},
    timeout=90,
)
p = r.json()
print(p["title"], p["channel"], p["videos_returned"])
# Essence of calculus 3Blue1Brown 12

After running the command, your terminal should look something like this:

Running the YouTube Playlist Scraper API

Authentication

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/playlist?api_key=YOUR_KEY&url=...

A free key is 1,000 requests, one time, with no card. Get one at chocodata.com.

Errors

Nothing below is billed: you are only charged on a 2xx.

Status error code Meaning Billed What to do
400 invalid_params Neither playlist_id nor url 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.
404 item_not_found The target returned 404: nothing exists at that URL. no Check the id or URL.
429 RATE_LIMITED Over 120 requests/60s, or over your plan's concurrency. no Back off and retry.
502 - YouTube did not return a parseable playlist page for this request. no Retry once after a few seconds.

A bad key, verbatim:

curl "https://api.chocodata.com/api/v1/youtube/playlist?api_key=totally_invalid_key&url=https://www.youtube.com/playlist?list=PLZHQObOWTQDMsr9K-rj53DwVRMYO3t5Yr"
{"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 playlist_id and not list:

{"error": "invalid_params", "issues": [{"code": "custom", "message": "youtube.playlist requires `playlist_id` or `url`", "path": ["playlist_id"]}]}

Two response shapes exist: auth and billing errors nest under error.code (uppercase), while scrape-layer errors are flat with a lowercase error string.

The scripts in this repo map every documented status onto an actionable message, so a typo'd key does not hand you a stack trace:

YouTube Playlist Scraper API error handling

Build the retry in. A 502 on this endpoint is retryable and uncharged. While capturing the samples for this README, one playlist call returned 502 and succeeded on the retry 8 seconds later. Both scripts in youtube_playlist_scraper_api_codes/ retry once before giving up.

Rate limits and concurrency

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. The examples use timeout=90 because the slowest call measured for the table below took 9.7s and you want headroom.

Fan out across many playlists with a thread pool, sized to stay inside both limits at once:

from concurrent.futures import ThreadPoolExecutor
import requests

def one(pid):
    r = requests.get("https://api.chocodata.com/api/v1/youtube/playlist",
                     params={"api_key": KEY, "playlist_id": pid}, timeout=90)
    return r.json() if r.status_code == 200 else None

with ThreadPoolExecutor(max_workers=8) as pool:
    playlists = [p for p in pool.map(one, playlist_ids) if p]

Playlist: video ids, titles, channels, views and publish dates

The playlist header plus one row per video, in playlist order.

Param Type Required Default Description
playlist_id string one of playlist_id/url - The playlist id (PL…, UU…, FL…, OL…, RD…), or any URL carrying ?list=.
url string (URL) one of playlist_id/url - A full playlist URL. The id is parsed out of ?list=, so a watch?v=…&list=… URL works too.
api_key string yes - Your key. Query parameter, not a header.
curl "https://api.chocodata.com/api/v1/youtube/playlist?api_key=YOUR_KEY&playlist_id=PLZHQObOWTQDMsr9K-rj53DwVRMYO3t5Yr"

Real response. videos cut to 2 of 12; all 7 top-level fields are present and verbatim, and all 8 keys of each video row are verbatim (full sample):

{
  "playlist_id": "PLZHQObOWTQDMsr9K-rj53DwVRMYO3t5Yr",
  "title": "Essence of calculus",
  "channel": "3Blue1Brown",
  "video_count": 12,
  "views": null,
  "videos": [
    {
      "position": 1,
      "id": "WUvTyaaNkzM",
      "title": "The essence of calculus",
      "url": "https://www.youtube.com/watch?v=WUvTyaaNkzM",
      "thumbnail": "https://i.ytimg.com/vi/WUvTyaaNkzM/hq720.jpg?sqp=-oaymwEcCK4FEIIDSEbyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLB_uI7Bw3WReKIm5DnBUeFDsrEt7w",
      "channel": "3Blue1Brown",
      "views": "11M views",
      "published": "9 years ago"
    },
    {
      "position": 2,
      "id": "9vKqVkMQHKk",
      "title": "The paradox of the derivative | Chapter 2, Essence of calculus",
      "url": "https://www.youtube.com/watch?v=9vKqVkMQHKk",
      "thumbnail": "https://i.ytimg.com/vi/9vKqVkMQHKk/hq720.jpg?sqp=-oaymwEcCK4FEIIDSEbyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLB5KsdqezjnwQDoDvEy9-OcgHDQIA",
      "channel": "3Blue1Brown",
      "views": "4.3M views",
      "published": "9 years ago"
    }
  ],
  "videos_returned": 12
}

videos[].id is the field most people come for: it is the 11-char watch id, ready to join against anything else keyed on YouTube videos, and url is the same id already expanded into a watch link.

video_count and videos_returned are two different numbers and you want both. videos_returned is how many rows are in videos[]. video_count is the playlist length read from YouTube's own page header. On a playlist that fits one page they agree. On a longer one they do not, and the gap is the useful part: it tells you the playlist is longer than what you are holding. The second committed sample, playlist_long.json, is exactly that case:

{
  "playlist_id": "PL8dPuuaLjXtNlUrzyH5r6jN9ulIgZBpdo",
  "title": "Computer Science",
  "channel": "CrashCourse",
  "video_count": 41,
  "views": 15962954,
  "videos_returned": 20
}

Four notes on the header fields, each measured across the playlists sampled on 2026-07-20:

  • video_count falls back to videos_returned when YouTube ships no page header. That was the case on both channel uploads playlists (UU… ids) sampled, which returned title: null too. On those, video_count reports the row count and is not the playlist's length. Check title for null if you need to tell the two situations apart.
  • views is the playlist's total view count from the header, and it is null more often than not: on 5 of the 9 playlists sampled, including the 12-video one above. The 41-video sample carries 15962954. Both are real; the header simply does not always ship the row.
  • channel is the uploader shared by every row. When rows come from different channels, as on a curated or trending playlist, it is null by design rather than picking one arbitrarily.
  • Row views and published are display strings, not numbers or dates: "11M views" and "9 years ago". They are what YouTube renders on the playlist page. An exact integer view count and an ISO publish timestamp are not on this surface at all; they live on each video's own watch page.

Runnable: youtube_playlist_scraper_api_codes/playlist.py


Export a playlist to CSV

The job most people arrive with: a playlist, and a need for it as a spreadsheet.

export CHOCODATA_API_KEY="your_key"
python youtube_playlist_scraper_api_codes/export_playlist_csv.py PLZHQObOWTQDMsr9K-rj53DwVRMYO3t5Yr

Pass any number of playlist URLs or bare ids. Each becomes one CSV named after its playlist id, and when the header count exceeds the rows returned the script says so rather than letting you assume the file is the whole playlist:

Exporting YouTube playlists to CSV

The CSV carries position, id, title, channel, views, published, url, one row per video, in playlist order.

Source: youtube_playlist_scraper_api_codes/export_playlist_csv.py


Measured latency

Ten consecutive calls to /youtube/playlist for the same playlist id, 3 seconds apart, on 2026-07-20:

Measured latency for the YouTube playlist endpoint

Metric Value
n 10
min 1,587 ms
median 2,900 ms
max 9,746 ms
non-2xx responses 0

Ten calls is a small sample and the spread is wide, which is why the examples set timeout=90 rather than sizing to the median.


License

MIT. See LICENSE.