YouTubeAnswer

YouTube API: get all videos from a channel

Google lists a channel's videos from its uploads playlist, 50 a call for 1 unit. Our API returns 30 a call, and a script saves a channel to CSV.

7 min read

Short answer

  • Google's YouTube Data API has no channel-videos call. You read the uploads playlist ID with channels.list, then page playlistItems.list at up to 50 videos and 1 unit a call.
  • search.list with a channel ID stops at 500 videos and draws on its own bucket of 100 calls a day, so the uploads playlist is the route.
  • ToolzerHub's channel videos route returns 30 videos a call from the Videos tab at 1 credit, about 34 credits per 1,000 videos. Shorts and live streams are separate routes.
On this page

Google's YouTube Data API has no single call for a channel's videos. You read the channel's uploads playlist ID with channels.list, then page playlistItems.list, 50 videos and 1 unit a call. Our API returns 30 videos a call from the Videos tab, and the script below pages a channel to the end into a CSV.

How Google lists a channel's videos

Google's playlistItems.list pages the channel's uploads playlist, whose ID sits in contentDetails.relatedPlaylists.uploads of the channel. channels.list takes forHandle, so @GoogleDevelopers resolves in one call. You follow nextPageToken until it is missing, and the latest uploads are the first page.

You need a Google account, a project and an API key. Take the publish time from contentDetails.videoPublishedAt; view counts need videos.list. Use Google's API for exact publish times, 50 items a page, or anything that needs your own authorization, such as editing playlists.

The costs:

  • channels.list: 1 unit.
  • playlistItems.list: 1 unit a call, up to 50 videos a page.
  • search.list with channelId: its own bucket of 100 calls a day, capped at 500 videos; see the YouTube search API quota.
  • Default quota: 10,000 units a day for the other endpoints; more needs an audit.

Get a channel's videos with ToolzerHub

We make ToolzerHub. Our YouTube API returns a public channel's videos as JSON with one API key and no Google project. GET /v2/youtube/channel/videos takes channel_id or channel_username (an @handle), sort_order (newest, oldest, popular) and page_cursor, and costs 1 credit a call. A page holds 30 videos; the last is shorter.

It returns the Videos tab. Shorts come from /v2/youtube/channel/shorts and live streams from /v2/youtube/channel/live-streams, 1 credit each. A video has videoId, title, viewCount, publishDate and lengthText, and the first page adds a meta block with videosCountText, the channel's stated total. publishDate is built from relative text such as 5 years ago, so older dates are approximate, and views are rounded.

Export a channel to CSV

Save this as youtube_channel_videos.py. It takes handles, channel URLs or IDs, writes one row per video ID, and stops at the end of the channel or at your page cap. Pass shorts or live for those tabs; Shorts come back without a date or length. A leading apostrophe in a cell, such as on a video ID that starts with a hyphen, keeps spreadsheets from reading it as a formula; strip it if you load the CSV into pandas.

python
import csv
import os
import re
import sys
import time

import requests

BASE = "https://api.toolzerhub.com"
HEADERS = {"x-api-key": os.environ.get("TOOLZERHUB_API_KEY", "")}
FIELDS = ["channel", "video_id", "url", "title", "published", "views", "duration", "members_only"]
ROUTES = {"videos": "videos", "shorts": "shorts", "live": "live-streams"}
calls = 0
FAILED = object()  # a 400, a 5xx or a 429/503 that outlasted the retries: different from "empty"


def cell(value):
    # Spreadsheets run text that starts with = + - or @ as a formula; an apostrophe keeps it text.
    if isinstance(value, str) and value[:1] in ("=", "+", "-", "@", "\t", "\r"):
        return "'" + value
    return value


def channel_param(ref):
    # An id (UC...), a /channel/UC... URL, an @handle or a youtube.com/@handle URL.
    m = re.search(r"(UC[\w-]{22})", ref)
    if m and (ref.startswith("UC") or "/channel/" in ref):
        return {"channel_id": m.group(1)}
    m = re.search(r"(@[\w.\-]+)", ref)
    return {"channel_username": m.group(1) if m else ref}


def get(route, params, tries=4):
    # 429 and 503 are retryable; the wait comes from the error body or the Retry-After header.
    global calls
    for attempt in range(tries):
        r = requests.get(f"{BASE}/v2/youtube/channel/{route}", headers=HEADERS, params=params, timeout=90)
        calls += 1
        if r.status_code == 400:  # bad input: skip this channel, keep the rest of the run
            print("bad request:", r.json()["error"]["message"].splitlines()[0])
            return FAILED
        if r.status_code not in (429, 503) or attempt == tries - 1:
            if r.status_code in (429, 503) or r.status_code >= 500:  # give up on this channel
                return FAILED
            if r.status_code >= 400:
                sys.exit(f"HTTP {r.status_code}: {r.text[:200]}")
            return r.json().get("data")  # None when there is nothing to return
        try:
            wait = r.json()["error"]["details"]["retryAfter"]
        except (ValueError, KeyError, TypeError):
            wait = None
        try:
            wait = int(wait or r.headers.get("Retry-After") or 5 * (attempt + 1))
        except ValueError:
            wait = 5 * (attempt + 1)
        time.sleep(min(wait, 60))


def export_channel(writer, ref, max_pages, sort, kind, seen):
    route = ROUTES[kind]
    params = {**channel_param(ref), "sort_order": sort}
    pages, rows, more, failed, stated, name = 0, 0, False, False, None, ref
    while pages < max_pages:
        data = get(route, params)
        if data is FAILED:
            failed = True
            break
        if not data or not data.get("data"):
            break  # null data or an empty list
        pages += 1
        meta = data.get("meta") or {}
        name = meta.get("title") or name
        stated = stated or meta.get("videosCountText")  # the channel block comes with sort newest only
        for v in data["data"]:
            vid = v.get("videoId")
            if not vid or vid in seen:  # keep one row per video id
                continue
            seen.add(vid)
            rows += 1
            writer.writerow({
                "channel": cell(name), "video_id": cell(vid),  # some IDs start with "-"
                "url": f"https://www.youtube.com/{'shorts/' if kind == 'shorts' else 'watch?v='}{vid}",
                "title": cell((v.get("title") or "").replace("\n", " ")),
                "published": v.get("publishDate") or "",  # as returned: older dates are approximate
                "views": v.get("viewCount") if v.get("viewCount") is not None else v.get("viewCountText") or "",
                "duration": v.get("lengthText") or "",
                "members_only": "yes" if v.get("isMemberOnly") else "",  # listed, but no view count
            })
        more = bool(data.get("page_cursor"))
        if not more:
            break
        params["page_cursor"] = data["page_cursor"]
        time.sleep(0.4)  # the free plan allows 3 requests a second
    if failed:
        status = " (gave up: bad request, server error or repeated 429/503)"
    elif not pages:
        status = " (nothing returned: no items on this tab)"
    elif more:
        status = " (stopped at the page cap, more available)"
    else:
        extra = f"; YouTube shows {stated} in all, Shorts and live streams included" if stated and kind == "videos" else ""
        status = f" (reached the end{extra})"
    print(f"{ref}: {rows} {kind}, {pages} pages{status}")
    return rows


def main(channels, max_pages="50", sort="newest", kind="videos", out="channel_videos.csv"):
    total, seen = 0, set()
    with open(out, "w", newline="", encoding="utf-8") as f:
        writer = csv.DictWriter(f, fieldnames=FIELDS)
        writer.writeheader()
        for ref in [c.strip() for c in channels.split(",") if c.strip()]:
            total += export_channel(writer, ref, int(max_pages), sort, kind, seen)
    print(f"wrote {total} rows to {out} in {calls} calls")


if __name__ == "__main__":
    args = sys.argv[1:]
    if not args or len(args) > 5 or (len(args) > 2 and args[2] not in ("newest", "oldest", "popular")) \
            or (len(args) > 3 and args[3] not in ROUTES):
        sys.exit("usage: python youtube_channel_videos.py @handle_or_url_or_id[,...] [max_pages] "
                 "[newest|oldest|popular] [videos|shorts|live] [file.csv]")
    if not HEADERS["x-api-key"]:
        sys.exit("set TOOLZERHUB_API_KEY first")
    main(*args)
bash
export TOOLZERHUB_API_KEY=your_key
python youtube_channel_videos.py "@Apple,https://www.youtube.com/@SpaceX,UCLA_DiR1FfKNvjuUpBHmylQ" 8 newest videos channel_videos.csv

It wrote 562 rows with no repeated ID. NASA stopped at the 8-page cap, 240 videos:

text
@Apple: 108 videos, 4 pages (reached the end; YouTube shows 221 videos in all, Shorts and live streams included)
https://www.youtube.com/@SpaceX: 214 videos, 8 pages (reached the end; YouTube shows 533 videos in all, Shorts and live streams included)
UCLA_DiR1FfKNvjuUpBHmylQ: 240 videos, 8 pages (stopped at the page cap, more available)
wrote 562 rows to channel_videos.csv

Videos, Shorts and live streams add up

We paged six channels to the end of newest, then their Shorts and live routes. "Stated" is videosCountText.

Channel Videos Shorts Live Sum Stated
@Apple 108 92 21 221 221
@SpaceX 214 0 319 533 533
@veritasium 451 90 0 541 541
@Tesla 379 119 41 539 539
@Nike 344 296 6 646 646
@MKBHD 1,727 123 5 1,855 1.8K

On the first five channels the three routes add up to the stated count. The Videos route alone is 214 of SpaceX's 533, because 319 of its uploads are live streams. Above 1,000 the stated text is rounded down, so MKBHD can't be checked exactly. oldest is the exact reverse of newest, and the meta block comes only with newest or no sort. Members-only videos are listed without views and count in the total.

What it costs

A call returns about 30 videos for 1 credit, so 1,000 videos cost about 34 credits and view counts come with them. Google's count is one channels.list plus one playlistItems.list per 50 videos.

Job Google ToolzerHub
1,000 videos 21 units 34 credits
1,727 videos (@MKBHD) 36 units 58 credits
Allowance 10,000 units a day 100 credits a month

The 100 free credits a month cover nearly 3,000 videos; $19 buys 10,000 credits that never expire, and plans for higher volume cover more channels. Set your key and run the script on your first channel.

For comments on those videos see how to get all comments.

We are not affiliated with Google or YouTube.

Get this data from your own code

The YouTube API returns it as JSON with one x-api-key header. 100 free credits monthly.