~ / guides / How to Scrape YouTube Playlists (2026)

How to Scrape YouTube Playlists (2026)

DT
Devon Tran
YouTube data engineer · about the author
the short version
  • A plain requests.get on a YouTube /playlist page returns HTTP 200, but the ordered video list is not in the HTML tags. Every item sits inside a JSON blob called ytInitialData, and from a datacenter IP you often get the consent page instead of the playlist.
  • yt-dlp is the most robust no-parser route. One extract_info call with extract_flat returned every video's id, title, and playlist position for me, and the project tracks YouTube's changes so the extractor keeps working.
  • The official playlistItems.list endpoint returns items 50 per page (the default is only 5) at 1 unit per call, so walking a full playlist is cheap against the 10,000-unit daily quota.
  • Our managed scraper API returns the ordered playlist as JSON in one GET with no proxies and no parsing. I ran it in July 2026 and got each item's position, video id, title, and channel back, with a cursor for the next page.

I tested how to scrape YouTube playlists and tried the quick way first: one requests.get on a /playlist?list=... page, then a regex to pull the video list out of the HTML. The request returned 200, the body was there, and a search for the video titles as <a> tags found nothing. The ordered list you see in a browser is not in the HTML as tags. To scrape a YouTube playlist you read the JSON the page already ships with, and from a server you first have to get past YouTube’s consent gate to reach it.

This guide is the Python I ran against live playlists in July 2026, the data each method returns, and where each one breaks. I will cover what a playlist actually exposes, the ytInitialData parsing route, the yt-dlp shortcut, the official Data API and its quota, a managed YouTube playlist scraper that returns the ordered list as parsed JSON, and where the legal line sits.

What data can you scrape from a YouTube playlist?

A YouTube playlist gives you the playlist title, the owning channel, the total item count, and then for each entry the position in the playlist, the video title, the eleven-character video id, the watch link, the channel that uploaded it, and the video length. That per-item payload is what sits behind every row on a /playlist page, and all of it is data YouTube already serves to a logged-out browser.

Here is what one playlist exposes and where each field lives:

Data pointWhere it livesEasiest method
Playlist title, ownerytInitialDataplaylistMetadataRenderer / headerparse JSON or API
Total item countplaylist header / playlists.listparse JSON or API
Position in playlistplaylistVideoRenderer.indexparse JSON or API
Video titleplaylistVideoRenderer.titleparse JSON or yt-dlp
Video id and watch linkplaylistVideoRenderer.videoIdparse JSON or yt-dlp
Uploading channelplaylistVideoRenderer.shortBylineTextparse JSON
Video lengthplaylistVideoRenderer.lengthTextparse JSON
Full list beyond ~100 itemscontinuation token → internal /youtubei/hidden API or scraper API

The pattern is the same on every row: the visible text you read in the browser is rendered by JavaScript, so it is not in the static HTML as tags. It is packed into one big JSON object the page ships with, called ytInitialData, which holds the whole playlist response. Once you know that, scraping a playlist becomes a JSON-parsing job, which is where the Python starts.

How do you scrape a YouTube playlist with Python?

The fastest way to scrape a YouTube playlist with Python is to request the /playlist page, pull the ytInitialData JSON blob out of the HTML, and walk it for each playlistVideoRenderer. This is the no-API route, and it works whenever you can get the real page instead of the consent wall.

Install the one library you need first:

pip install requests

Then fetch the playlist page and lift the JSON object out of it. A naive re.search for ytInitialData = (\{.*?\}); breaks on the nested braces inside the object, so match the balanced object instead:

import json
import re
import requests

UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
      "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36")

def fetch_playlist(url):
    s = requests.Session()
    s.headers.update({"User-Agent": UA, "Accept-Language": "en-US,en;q=0.9"})
    s.cookies.set("SOCS", "CAI", domain=".youtube.com")  # nudge past consent
    r = s.get(url, timeout=20)
    if "ytInitialData" not in r.text:
        title = re.search(r"<title>(.*?)</title>", r.text)
        raise RuntimeError(
            f"No ytInitialData. Got <title> {title.group(1)!r} "
            f"- likely the consent interstitial."
        )
    return r.text

def extract_initial_data(html):
    start = html.find("ytInitialData")
    brace = html.find("{", start)
    depth, in_str, esc = 0, False, False
    for i in range(brace, len(html)):
        c = html[i]
        if in_str:
            esc = (c == "\\" and not esc)
            if c == '"' and not esc:
                in_str = False
        elif c == '"':
            in_str = True
        elif c == "{":
            depth += 1
        elif c == "}":
            depth -= 1
            if depth == 0:
                return json.loads(html[brace:i + 1])
    raise RuntimeError("Could not balance ytInitialData JSON")

def parse_playlist(data):
    videos = []
    def walk(node):
        if isinstance(node, dict):
            pv = node.get("playlistVideoRenderer")
            if pv and pv.get("videoId"):
                videos.append({
                    "position": pv.get("index", {}).get("simpleText"),
                    "videoId": pv["videoId"],
                    "title": pv["title"]["runs"][0]["text"],
                    "channel": pv.get("shortBylineText", {})
                                 .get("runs", [{}])[0].get("text"),
                })
            for v in node.values():
                walk(v)
        elif isinstance(node, list):
            for v in node:
                walk(v)
    walk(data)
    title = (data.get("metadata", {})
                 .get("playlistMetadataRenderer", {})
                 .get("title"))
    return {"playlist_title": title, "videos": videos}

if __name__ == "__main__":
    url = "https://www.youtube.com/playlist?list=YOUR_PLAYLIST_ID"
    pl = parse_playlist(extract_initial_data(fetch_playlist(url)))
    print(pl["playlist_title"], "-", len(pl["videos"]), "videos")
    for v in pl["videos"][:5]:
        print(v["position"], v["videoId"], "-", v["title"], "-", v["channel"])

When I ran this from a residential connection in July 2026, it printed the playlist title and an ordered list: the position, the eleven-character videoId, the title, and the uploading channel for each entry. The walk function is deliberate. YouTube nests playlistVideoRenderer blocks several levels deep inside ytInitialData, and the exact path changes when they ship a layout update, so recursively collecting every renderer survives those reshuffles better than a hardcoded key chain.

Two limits remain even when the page loads. From a datacenter IP the same code raises the RuntimeError, because the <title> comes back as 'Before you continue to YouTube' and there is no ytInitialData to parse. And the page only holds roughly the first 100 items, after which YouTube hands you a continuation token you post to its internal endpoint to page through the rest. That pagination is where a from-scratch playlist scraper turns into a maintenance project, which is the reason the next methods exist.

Can you scrape a YouTube playlist with yt-dlp?

Yes, and yt-dlp is the most robust no-parser route to a full playlist in Python. The open-source project already knows how to read YouTube’s internal data, including the continuation paging, so you hand it a playlist URL and it returns every entry without you touching ytInitialData.

Install it first:

pip install yt-dlp

Set extract_flat so it lists each video without opening every watch page, which keeps even a large playlist to a handful of round trips. This is the opposite of the noplaylist flag you use for single videos:

from yt_dlp import YoutubeDL

opts = {"quiet": True, "extract_flat": True, "skip_download": True}
url = "https://www.youtube.com/playlist?list=YOUR_PLAYLIST_ID"

with YoutubeDL(opts) as ydl:
    info = ydl.extract_info(url, download=False)

print(info.get("title"), "-", info.get("playlist_count"), "videos")
for i, entry in enumerate(info["entries"], start=1):
    print(i, "-", entry.get("id"), "-", entry.get("title"), "|", entry.get("channel"))

When I ran that against a public playlist, it returned the playlist title, the item count, and an entries list in playlist order, each entry carrying id, title, url, and channel. The trade against the hand-rolled parser is field coverage: flat mode gives you fewer fields per item (no view-count string, no length text), but it walks the entire playlist past the 100-item wall and it survives YouTube’s layout changes because the maintainers update the extractor for you. For a clean list of video ids to feed into a per-video lookup afterward, this is the shortest path. When you need the richer per-item fields or an official contract, the Data API is next.

How do you scrape a YouTube playlist with the YouTube Data API?

The YouTube Data API v3 returns playlist contents as clean JSON through playlistItems.list, in exchange for a Google Cloud API key and a daily quota. It sidesteps the consent interstitial entirely, because you are calling an API host instead of loading a web page, and it is the route Google sanctions.

You enable the API in the Google Cloud Console, create a key, then read the playlist with the google-api-python-client library. The one parameter people miss is maxResults: per the playlistItems.list reference its default is only 5, so you set it to 50 or you page five items at a time:

from googleapiclient.discovery import build

youtube = build("youtube", "v3", developerKey="YOUR_API_KEY")
playlist_id = "YOUR_PLAYLIST_ID"

# Playlist metadata: title, channel, and the total item count
meta = youtube.playlists().list(
    part="snippet,contentDetails",
    id=playlist_id,
).execute()["items"][0]
print(meta["snippet"]["title"], "-", meta["contentDetails"]["itemCount"], "videos")

# Every item, 50 per page, 1 unit per page
videos, page = [], None
while True:
    resp = youtube.playlistItems().list(
        part="snippet,contentDetails",
        playlistId=playlist_id,
        maxResults=50,
        pageToken=page,
    ).execute()
    for item in resp["items"]:
        s = item["snippet"]
        videos.append({
            "position": s["position"],                 # zero-based order
            "videoId": s["resourceId"]["videoId"],
            "title": s["title"],
        })
    page = resp.get("nextPageToken")
    if not page:
        break

print("collected", len(videos), "videos in playlist order")

The cost structure is the part that decides whether this scales. Per Google’s quota cost documentation, a read like playlistItems.list or playlists.list costs 1 unit, against a default allocation of 10,000 units per day. So walking one playlist is cheap: a 500-video playlist is 10 pages, about 10 units, and even a few hundred playlists a day fits inside the free quota. The search.list call costs 100 units, but you do not need it here, because you already have the list id.

Two catches are worth knowing before you commit. playlistItems.list will not return items from a playlist you cannot access without OAuth, so private playlists need an authenticated flow rather than a plain key. And it returns the video id, title, and position cleanly but not the view count or duration, so if you need those you collect the ids here and make a separate videos.list call, which spends more quota. For scale beyond the quota, or data the API gates, the managed route is the practical answer.

A scraper API removes the two things that break a do-it-yourself playlist scraper: the consent interstitial and the datacenter-IP blocks. You send a playlist URL and get the ordered items back as parsed JSON, with consent clearing and proxy rotation handled on the server side, and pagination as a clean cursor rather than a continuation-token loop. This is the route I reach for once a job runs on a schedule.

The request is one call against our API, keyed with your API key. Here is the playlist endpoint:

curl "https://api.youtubescraperapi.com/api/v1/youtube/playlist?url=https://www.youtube.com/playlist?list=YOUR_PLAYLIST_ID&api_key=$API_KEY"

The same call works from Python. Page 1 returns the playlist header plus the first batch of items in order; to walk the rest you follow next_page_url while has_more is true, which replaces the manual continuation loop from the ytInitialData section:

import os
import requests

BASE = "https://api.youtubescraperapi.com"
key = os.environ["API_KEY"]

data = requests.get(
    f"{BASE}/api/v1/youtube/playlist",
    params={"url": "https://www.youtube.com/playlist?list=YOUR_PLAYLIST_ID",
            "api_key": key},
    timeout=60,
).json()

print(data["playlist_title"], "-", data["video_count"], "videos")
for v in data["videos"][:5]:
    print(v["position"], "-", v["id"], "-", v["title"], "|", v["channel"])

# Each call returns one page and is one flat charge. Follow the cursor for more.
while data["has_more"]:
    data = requests.get(BASE + data["next_page_url"],
                        params={"api_key": key}, timeout=60).json()
    print("page", data["page"], "->", data["videos_count"], "videos")

The response comes back as the data object, with the playlist header fields and the ordered list under videos, each item carrying its position, video id, title, and channel:

{
  "playlist_id": "YOUR_PLAYLIST_ID",
  "playlist_title": "Example Course Playlist",
  "channel": "Example Channel",
  "video_count": 214,
  "page": 1,
  "videos_count": 100,
  "videos": [
    {
      "position": 1,
      "id": "XVv6mJpFOb0",
      "title": "Lesson 1 - Getting Started",
      "link": "https://www.youtube.com/watch?v=XVv6mJpFOb0",
      "channel": "Example Channel",
      "length": "12:04"
    }
  ],
  "has_more": true,
  "next_page_url": "/api/v1/youtube/playlist?url=...&page=2"
}

When I ran this in July 2026, the positions matched the live playlist order, and each id was the same eleven-character watch id you would feed back into a single-video lookup. The pagination is the part that saves the most maintenance: each page is one predictable, flat-billed call instead of the brittle continuation plumbing you would otherwise own. For a one-off pull of a single playlist, the Data API is fine and free. For continuous collection across many playlists, offloading the consent gate and proxy rotation is the cheaper path once you price in your own time, and a free tier covers 1,000 requests, enough to run the playlist endpoint against your own targets before you commit. You can compare the managed options in the best YouTube scrapers for 2026, and if you also need the uploads playlist behind a whole channel, that is the same shape in how to scrape YouTube channels with Python.

Scraping a public YouTube playlist sits in the same grey area as scraping other public YouTube surfaces: the /playlist page is served to a logged-out browser, but YouTube’s Terms of Service restrict automated access without permission. Playlist data is mostly factual metadata, titles, ids, and ordering, rather than the videos themselves, which is a different copyright posture from downloading content.

One nuance is specific to playlists and worth reading before you scale. In its robots.txt, YouTube disallows /results and the internal /youtubei/ paths, but it does not list /playlist itself. So the playlist page is not robots-disallowed, while the continuation calls that page through a long playlist do hit the disallowed /youtubei/ path. That is a real line: the first page of a playlist is a different matter from deep automated paging through it.

On the public-data question, US courts have read the Computer Fraud and Abuse Act narrowly, and in hiQ Labs v. LinkedIn the Ninth Circuit affirmed in 2022 that scraping public website data likely does not violate the CFAA. Public data and a site’s own terms are still different matters, so I walk through the full picture in is scraping YouTube legal, which is the place to settle it before you collect at scale. The short version: public, factual playlist metadata at modest volume is the low-risk end, and aggressive automated collection is where the Terms of Service bite.

Which method should you use?

The right method for scraping a YouTube playlist comes down to how many playlists you need, whether you want the richer per-item fields, and how much engineering time you want to spend on YouTube’s layout and blocking. The ytInitialData parser is free and returns the most fields, if you are willing to fix it when YouTube reshapes the JSON and handle the consent wall yourself. yt-dlp is the most robust do-it-yourself route, with fewer fields but an extractor someone else maintains and full paging past the 100-item wall. The official playlistItems.list API is clean, sanctioned, and cheap on quota, but caps you at your daily allocation and skips view counts. A managed scraper API fits scale, returning the ordered list as JSON with the consent gate and proxies handled, and it is the one I reach for once a job runs on a schedule.

For a single playlist you pull once, yt-dlp or the Data API is enough. For continuous collection across many playlists, or scraping playlists from a server where the consent interstitial bites, offloading the blocking and the parser upkeep is usually the cheaper path once you price in your own time chasing changes.

FAQ

How do you get all video IDs from a YouTube playlist?

Three routes return every video id in a playlist. You can parse the ytInitialData JSON on the /playlist page and read each playlistVideoRenderer.videoId, run yt-dlp with extract_flat and read entry['id'] off each entry, or call the Data API's playlistItems.list and read snippet.resourceId.videoId, paging with nextPageToken until it is gone. The page and API routes both keep the playlist order alongside each id.

How many videos can you scrape from one playlist?

There is no fixed cap on the Data API route: playlistItems.list returns up to 50 items per page and you follow nextPageToken until the whole list is read, at 1 unit per page. The raw /playlist page loads roughly the first 100 items, after which YouTube hands you a continuation token you post back to its internal endpoint to page through the rest. A managed API follows the same cursor for you.

Does scraping a playlist keep the video order?

Yes. The playlist order is part of the data on every route. In ytInitialData each playlistVideoRenderer carries an index, the Data API returns snippet.position as a zero-based index, and yt-dlp preserves the list order in its entries array. So a scraped playlist reproduces the exact sequence a viewer sees, which matters when the order is the point, like a course or a ranked series.

Can you scrape a private or unlisted YouTube playlist?

You can scrape an unlisted playlist if you have its list id, because unlisted playlists are reachable by anyone with the link and render the same public ytInitialData. A private playlist is not reachable without the owner's authenticated session, so scraping cannot reach it. For private playlists you own, the Data API with OAuth is the sanctioned route, not page scraping.

Why does my script get the consent page instead of the playlist?

A logged-out request from a datacenter or EU-region IP frequently receives YouTube's consent interstitial, titled 'Before you continue to YouTube', instead of the playlist HTML. The request still returns HTTP 200, so the failure is silent: your script captures the consent page and finds no ytInitialData. A residential IP, or a scraper API that clears consent server-side, returns the real playlist.

DT
Devon Tran
I've built YouTube data pipelines for years. On youtubescraperapi.com I run YouTube scraping methods against live pages and publish what actually holds up.