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.listwithchannelId: 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.
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)
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:
@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 | 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.