How to Extract Transcripts from a YouTube Playlist (in Bulk)

How to Extract Transcripts from a YouTube Playlist (in Bulk)

By Nikhil KumarPublished May 4, 2026Last updated June 13, 202610 min read

A 500-video playlist. One script. About three minutes of wall time.

That is the bar for this guide. By the end, you will have working Python and Node.js code that walks any YouTube playlist, pulls every transcript, retries on rate limits, and skips videos you already grabbed last week. No browser automation. No headless Chrome. No proxy rotation. Just two endpoints and a loop. If you have ever tried to feed a course or podcast feed into an LLM, you know how painful the manual version gets. Let's fix that.

The two-step approach (list then transcripts)

Two-step flow listing playlist videos then fetching transcripts in batches

A youtube playlist transcript job is really two jobs glued together.

First, you need every video ID in the playlist. Playlists can hold up to 5,000 videos, and the listing comes back paginated, roughly 100 videos per page. You walk the pages until there are none left.

Second, you fetch the transcript for each video ID. One request per video. That is the part that adds up at scale, so it deserves real retry logic.

The split matters because the two halves have different failure modes. Listing rarely fails. Transcripts sometimes do (private video, captions disabled, age-gated). You want the listing pass to finish cleanly so you have a complete inventory before you start burning credits on transcripts.

Step 1: list every video in the playlist

The TranscriptAPI playlist endpoint is one call:

GET /api/v2/youtube/playlist/videos?playlist=PLxxxx

You get back a JSON payload with videos, a continuation token, and a has_more flag. If has_more is true, you call again with &continuation=<token>. Repeat until it flips to false.

That is 1 credit per page. A 1,000-video playlist takes 10 pages, so 10 credits for the entire inventory. Cheap.

The playlist parameter accepts either the raw playlist ID (starts with PL, UU, LL, FL, etc.) or a full YouTube URL. Pass whichever you have.

Step 2: fetch transcripts in batches

Once you have the IDs, you hit the transcript endpoint per video:

GET /api/v2/youtube/transcript?video_url=https://youtube.com/watch?v=VIDEO_ID

One credit per successful call. Failed calls are not charged, which matters when 4% of your playlist turns out to be private or caption-less.

The format parameter controls the shape: text returns plain prose, json returns timestamped segments. For LLM ingestion, plain text is usually what you want. For a search index where you need to deep-link, use json and keep the timestamps.

Run these in parallel, but not too parallel. The Monthly plan caps you at 200 requests per minute, the Annual plan at 300. We will get to backoff in a minute.

Code: complete Python version

Python script processing a YouTube playlist with concurrency

Here is the full thing in Python using requests. Drop in your token, point it at a playlist, run it.

import os
import time
import json
import requests
from concurrent.futures import ThreadPoolExecutor, as_completed

API = "https://transcriptapi.com/api/v2"
TOKEN = os.environ["TRANSCRIPTAPI_TOKEN"]
HEADERS = {"Authorization": f"Bearer {TOKEN}"}

def list_playlist(playlist_id):
    videos, cont = [], None
    while True:
        params = {"playlist": playlist_id}
        if cont:
            params["continuation"] = cont
        r = requests.get(f"{API}/youtube/playlist/videos",
                         headers=HEADERS, params=params, timeout=30)
        r.raise_for_status()
        data = r.json()
        videos.extend(data.get("videos", []))
        if not data.get("has_more"):
            break
        cont = data["continuation"]
    return videos

def fetch_transcript(video_id, retries=3):
    url = f"https://youtube.com/watch?v={video_id}"
    for attempt in range(retries):
        r = requests.get(f"{API}/youtube/transcript", headers=HEADERS,
                         params={"video_url": url, "format": "text"}, timeout=60)
        if r.status_code == 429:
            wait = int(r.headers.get("Retry-After", 5))
            time.sleep(wait)
            continue
        if r.status_code >= 500:
            time.sleep(2 ** attempt)
            continue
        if r.status_code == 200:
            return r.json()
        return None  # 404, captions disabled, etc.
    return None

def run(playlist_id, out_path):
    videos = list_playlist(playlist_id)
    print(f"Found {len(videos)} videos")
    results = {}
    with ThreadPoolExecutor(max_workers=8) as pool:
        futs = {pool.submit(fetch_transcript, v["video_id"]): v for v in videos}
        for fut in as_completed(futs):
            v = futs[fut]
            t = fut.result()
            if t:
                results[v["video_id"]] = {"title": v.get("title"), "transcript": t}
    with open(out_path, "w") as f:
        json.dump(results, f, indent=2)

if __name__ == "__main__":
    run("PLxxxxx", "transcripts.json")

Eight workers keeps you well under the 200 req/min ceiling on the Monthly plan. Bump to twelve if you are on Annual.

Code: complete Node.js version

Same logic, in modern Node using built-in fetch (Node 18+):

import fs from 'node:fs/promises';

const API = 'https://transcriptapi.com/api/v2';
const TOKEN = process.env.TRANSCRIPTAPI_TOKEN;
const headers = { Authorization: `Bearer ${TOKEN}` };

async function listPlaylist(playlistId) {
  const videos = [];
  let cont = null;
  while (true) {
    const params = new URLSearchParams({ playlist: playlistId });
    if (cont) params.set('continuation', cont);
    const r = await fetch(`${API}/youtube/playlist/videos?${params}`, { headers });
    if (!r.ok) throw new Error(`List failed: ${r.status}`);
    const data = await r.json();
    videos.push(...(data.videos || []));
    if (!data.has_more) break;
    cont = data.continuation;
  }
  return videos;
}

async function fetchTranscript(videoId, retries = 3) {
  const url = `https://youtube.com/watch?v=${videoId}`;
  for (let i = 0; i < retries; i++) {
    const r = await fetch(
      `${API}/youtube/transcript?video_url=${encodeURIComponent(url)}&format=text`,
      { headers }
    );
    if (r.status === 429) {
      const wait = Number(r.headers.get('Retry-After') || 5);
      await new Promise(res => setTimeout(res, wait * 1000));
      continue;
    }
    if (r.status >= 500) {
      await new Promise(res => setTimeout(res, 2 ** i * 1000));
      continue;
    }
    if (r.ok) return await r.json();
    return null;
  }
  return null;
}

async function run(playlistId, outPath) {
  const videos = await listPlaylist(playlistId);
  console.log(`Found ${videos.length} videos`);
  const results = {};
  const concurrency = 8;
  for (let i = 0; i < videos.length; i += concurrency) {
    const batch = videos.slice(i, i + concurrency);
    const ts = await Promise.all(batch.map(v => fetchTranscript(v.video_id)));
    batch.forEach((v, j) => {
      if (ts[j]) results[v.video_id] = { title: v.title, transcript: ts[j] };
    });
  }
  await fs.writeFile(outPath, JSON.stringify(results, null, 2));
}

run('PLxxxxx', 'transcripts.json');

Same shape, same retry rules. Pick the language your team already uses.

Handling pagination (continuation tokens)

The pagination model is simple. The first call returns up to 100 videos plus a continuation token. You pass that token back to get the next 100. The server returns has_more: false when there is nothing left.

You do not need to track page numbers. You do not need to compute offsets. The token encodes everything.

Three things will trip you up if you skip them:

  • Forgetting to URL-encode the token (it contains = and + characters).
  • Stopping on an empty videos array instead of has_more: false. A page can legally come back empty if filtered.
  • Caching the first response and re-replaying it. Continuation tokens are not stable across days.

For a 5,000-video playlist that is 50 pages, 50 credits, maybe 5 seconds of wall time. The listing pass is never your bottleneck.

Handling rate limits and retries

Here is where bulk jobs go sideways. You launch 50 parallel requests, hit 429, panic, and your whole script dies.

The fix is two lines of logic:

  1. On 429 Too Many Requests, read the Retry-After header and sleep that many seconds.
  2. On 5xx, retry with exponential backoff (1s, 2s, 4s).

The code samples above already do this. The thing most people miss: do not retry on 4xx responses other than 429. A 404 means the video is gone or private. A 422 means your URL is malformed. Retrying makes both worse.

Sound familiar? I have watched a teammate burn 4,000 credits retrying the same dead videos in a tight loop. The cap is your friend.

A safer pattern: cap concurrency below your plan's per-minute limit divided by 60. Monthly plan is 200/min, so 3 concurrent workers each making 1 req/sec is dead safe. Eight workers averaging one request every 0.4 seconds is also fine. Twenty workers hammering as fast as possible is not.

Bonus: skip videos you've already processed (idempotency)

Bulk jobs fail. Your laptop sleeps. Your VPN drops. The script crashes at video 847.

You do not want to start over.

The fix is a one-line idempotency check: keep a set of processed video IDs on disk and skip them on restart. A JSON file works fine for under 10,000 videos.

import json, os

CACHE = "processed.json"
processed = set(json.load(open(CACHE))) if os.path.exists(CACHE) else set()

def mark_done(video_id):
    processed.add(video_id)
    json.dump(list(processed), open(CACHE, "w"))

# In your main loop:
for v in videos:
    if v["video_id"] in processed:
        continue
    t = fetch_transcript(v["video_id"])
    if t:
        save_transcript(v["video_id"], t)
        mark_done(v["video_id"])

Now your script is restartable. Crash at video 847, run it again, it picks up at 848. No double charges, no duplicate work.

For larger jobs (10,000+ videos), swap the JSON file for SQLite. Speaking of which.

Bonus: store everything in SQLite for searching

Once you have a few thousand transcripts on disk, you will want to search them. A flat folder of JSON files is fine for storage, terrible for grep at scale.

SQLite with FTS5 is the answer:

import sqlite3

db = sqlite3.connect("transcripts.db")
db.executescript("""
CREATE TABLE IF NOT EXISTS videos (
    video_id TEXT PRIMARY KEY,
    title TEXT,
    playlist_id TEXT,
    fetched_at TEXT
);
CREATE VIRTUAL TABLE IF NOT EXISTS transcripts
    USING fts5(video_id UNINDEXED, body);
""")

def save_transcript(video_id, title, playlist_id, body):
    db.execute("INSERT OR REPLACE INTO videos VALUES (?,?,?,datetime('now'))",
               (video_id, title, playlist_id))
    db.execute("INSERT INTO transcripts(video_id, body) VALUES (?,?)",
               (video_id, body))
    db.commit()

Now you can run full-text queries across your whole playlist:

SELECT v.title, snippet(transcripts, 1, '<b>', '</b>', '...', 10)
FROM transcripts JOIN videos v ON v.video_id = transcripts.video_id
WHERE transcripts MATCH 'kubernetes secrets';

That gives you a working search engine for your playlist in about 30 lines of code. The processed-set trick from above lives in the same videos table for free.

Cost math: a 1,000-video playlist costs ~1,010 credits

Calculator showing one thousand videos costs about ten dollars

Let's do the math out loud, because vague pricing is annoying.

A 1,000-video playlist breaks down like this:

  • Listing: 10 pages at 1 credit each = 10 credits
  • Transcripts: 1,000 videos at 1 credit each = 1,000 credits
  • Total: 1,010 credits

That is roughly one month of the cheapest paid plan. The Monthly plan is $5 for 1,000 credits, and you have 100 free credits sitting there from the free tier. So a single 1,000-video pull is essentially "one month of TranscriptAPI." If you do this once a month, you never need top-ups.

For a 5,000-video pull (think: a full conference channel), you are at 5,050 credits. That is one Monthly plan plus four 1,000-credit top-ups at $2.50 each, for $15 total. On the Annual plan, top-ups drop to $1.50 per 1,000, so the same job costs $11.

Failed requests do not count. If 4% of your playlist has captions disabled, you are billed for the 960 that worked, not the 40 that did not.

What to do with the data (3 examples)

Once you have a folder full of transcripts, the question is what now. Three real things teams do with playlist dumps:

  1. Feed an LLM for Q&A. Embed each transcript chunk, store in a vector DB (Chroma, Qdrant, pgvector), wire to a chat UI. You now have a custom GPT for your course or podcast.
  2. Build a search engine with deep links. Use the JSON format with timestamps, index in Meilisearch or Typesense, and link results to youtube.com/watch?v=ID&t=SECONDS. Click a result, jump to the exact moment.
  3. Generate summaries and show notes. Pipe each transcript through a summarization prompt to get episode-level show notes, key quotes, and chapter markers. Useful for podcast hosts who never wrote any.

The transcript itself is the boring part. What you stack on top is where the value lives.

How to put this into action

  1. Grab a free TranscriptAPI key at transcriptapi.com (100 credits, no card).
  2. Copy the Python or Node.js script above into a file, set TRANSCRIPTAPI_TOKEN in your env.
  3. Replace PLxxxxx with your actual playlist ID or URL.
  4. Run it on a small test playlist first (10 videos) to confirm output shape.
  5. Add the SQLite + idempotency layer once you trust the basic loop.
  6. Scale up to your real playlist. Watch the credits panel for a sanity check.

The bottom line

A youtube playlist transcript job is two endpoints, one loop, and a bit of retry logic. Pagination is a token, not a math problem. Rate limits are a header, not a guess. A 1,000-video pull costs about the same as one month of the cheapest paid plan, and crashes do not cost you twice if you keep a processed-IDs file. So: which playlist are you going to run this against first?

Frequently Asked Questions

Why should listing a playlist and fetching transcripts be two separate steps?
They fail differently. Listing a playlist rarely fails; transcript fetching sometimes does — private videos, disabled captions, age-gated content. Finishing the listing pass first gives you a complete inventory before you spend any transcript credits, and lets you resume the fetch step on its own if it's interrupted, without re-paying for the listing. It's the split that matters most on large jobs.
How do I paginate a 1,000-video playlist without losing my place?
Walk the playlist endpoint using its continuation token and has-more flag: while more pages remain, call the endpoint again with the latest token. Each page is roughly 100 videos and costs 1 credit, so a 1,000-video playlist uses about 10 credits for the full inventory. Collect all the video IDs before starting transcript fetches, and store the token at each step so a crash doesn't force you to restart from the beginning.
What rate limits apply to parallel transcript fetching?
200 requests per minute on the monthly plan and 300 on the annual plan. A bounded thread or worker pool keeps you within those limits, and on a 429 you back off exponentially. Make the job idempotent — check whether a transcript file already exists before fetching — so re-running after a failure skips the videos you've already pulled instead of fetching and paying for them again.
Share