HasData
Back to all posts

How to Scrape YouTube Data for Free with Python (2026)

YouTube is the world’s leading video platform, hosting millions of creators and viewers every day. But besides watching videos, scraping YouTube data gives content creators and marketers a working feed of it. It allows you to track emerging trends, assess the performance of your own videos, and generate new ideas for future content.

By analyzing data from other channels, creators can identify opportunities to innovate or enhance their material. Scraping comments, in particular, can provide deeper insight into audience reactions, helping creators make changes to increase engagement and improve content based on real feedback.

Preparing for YouTube Scraping

Every script below is Python, and each one is also in Google Colaboratory so it runs in the cloud without a local install. If Python is new, our Python scraping for beginners guide starts from zero.

Methods for Scraping YouTube Data

There are four ways to collect data from YouTube, and this article works through the ones that automate:

  1. Manual data collection. Visiting the pages and copying what you see works once, and stops working the day the task becomes monitoring.
  2. Using the YouTube API. Google’s own interface to the platform, which returns documented fields and meters what you take. It gets the longest treatment below.
  3. Using plugins or scraping services. If you prefer to obtain ready-to-use data immediately, you can explore existing solutions, such as plugins or online services designed for scraping YouTube data.
  4. Scraping YouTube directly. This can be done using specialized libraries or any other tool that enables scraping, such as Selenium, Pyppeteer, or Playwright.

The API comes first, since it’s the one route YouTube supports, and the direct-scraping routes follow for the data it meters or leaves out.

The three routes differ enough that the choice deserves a table before any code:

YouTube Data APIyt-dlpSelenium / Playwright
AuthAPI keynonenone
Limits10,000 units/day, search capped at 100 calls/daynone stated, CAPTCHAs at volumenone stated, CAPTCHAs at volume
Commentsup to 100 per pageweakrender after scrolling, selectors fragile
Fieldsstable and documented74 keys per videowhatever the page renders
Breaks whenquota runs outYouTube changes internals, monthly releases keep upthe page layout changes

The rest of the article walks each column, in that order.

Types of Data You Can Extract from YouTube

The pages fall into four kinds, and each carries a different set of fields:

  1. Video page.
  2. Channel page.
  3. Search results page.
  4. Playlists.

The video page comes first.

A YouTube video page with red boxes around the player, the title, the like count, the view count and description block, the comment count and the first comment threads

YouTube video example

From the video page, you can obtain the download link for the video, its title, description, view count, like count, and comment count, as well as the publication date and the comments themselves.

Next, let’s move on to the channel page:

A YouTube channel page, the source of the channel name, the owner, the subscriber count and the number of videos

YouTube channel example

In addition to recommendations, popular videos, and other features, the channel page provides information such as the channel name, owner, subscriber count, and total number of videos.

Next we can navigate to the search results page to find relevant videos:

A YouTube search results page, a list of matching videos with a short description under each

YouTube search results example

From this page, we can get a list of videos that match the specified filters, along with a brief description of each video. Depending on the selected filters, this may include relevant videos, channels, playlists, and movies.

Playlists are the last of the four:

A YouTube playlist page, the source of the playlist title, its author, the number of videos, the view count and the list itself

YouTube playlist example

On the playlist page, we can obtain essential information about the playlist, including its title, author, the number of videos included, view count, and description, as well as a list of videos with brief descriptions.

No single method reaches every field listed above. Each section below says what its route covers and where it stops.

Using YouTube Data API

You’ll need an API key to access the service, and the official YouTube documentation covers getting one. It’s free, and for most of what follows it’s the right tool.

A project gets 100 search.list calls a day, a separate bucket from the 10,000 units that cover everything else, and each page of a paged search counts as another call. Metadata for videos you can already name runs to thousands a day at no cost. Finding videos you can’t name stops at a hundred queries. The second limit is captions.download, which requires permission to edit the video and answers 403 otherwise, so transcripts of anyone else’s video aren’t available through the API at any quota.

Every request below hits the same base URL, with only the method changing:

https://www.googleapis.com/youtube/v3

The scripts are also in Google Colaboratory, where they run in the cloud without a local Python:

  1. YouTube Channel Information. This script allows you to retrieve and save data for a specific channel of interest.
  2. YouTube Video Data. This script provides essential information about a video you’re interested in.
  3. YouTube Playlist Data. This script enables you to obtain information about a playlist you want to explore quickly.
  4. YouTube Comments Data. This is suitable for tracking comments, including replies to existing ones.
  5. YouTube Search Data. With this script, you can retrieve up-to-date information with a list of relevant videos based on your query.

You can find a complete list of all of the API methods on the official YouTube API page.

YouTube Channel Scraping

The channels method takes a channel ID, a legacy username, or a handle like @google through the forHandle parameter. The script below fetches one channel and keeps the fields that matter for monitoring:

import requests
import json

API_KEY = 'YOUR-API-KEY'
CHANNEL_ID = 'UCK8sQmJBp8GCxrOtXWBpyEA'
PARTS = 'snippet,contentDetails,statistics'

url = f'https://www.googleapis.com/youtube/v3/channels?part={PARTS}&id={CHANNEL_ID}&key={API_KEY}'
response = requests.get(url).json()

item = response['items'][0] if response.get('items') else {}
snippet = item.get('snippet', {})
statistics = item.get('statistics', {})

channel_info = {
    'title': snippet.get('title'),
    'published_at': snippet.get('publishedAt'),
    'subscribers': statistics.get('subscriberCount'),
    'views': statistics.get('viewCount'),
    'videos': statistics.get('videoCount'),
    'uploads_playlist': item.get('contentDetails', {})
                            .get('relatedPlaylists', {}).get('uploads'),
}
print(channel_info)

with open('channel_data.json', 'w', encoding='utf-8') as f:
    json.dump(channel_info, f, ensure_ascii=False, indent=4)

The run against Google’s channel returns the title, 14.6 million subscribers, and the uploads playlist ID. That last field is the one to keep. It’s the channel ID with UC swapped for UU, and it lists every upload without spending a search call.

One call costs one quota unit.

YouTube Video Scraping

The videos method works the same way, keyed by video ID:

import requests

API_KEY = 'YOUR-API-KEY'
VIDEO_ID = 'dQw4w9WgXcQ'
PARTS = 'snippet,contentDetails,statistics'

url = f'https://www.googleapis.com/youtube/v3/videos?part={PARTS}&id={VIDEO_ID}&key={API_KEY}'
response = requests.get(url).json()

item = response['items'][0] if response.get('items') else {}
video_info = {
    'title': item.get('snippet', {}).get('title'),
    'channel': item.get('snippet', {}).get('channelTitle'),
    'published_at': item.get('snippet', {}).get('publishedAt'),
    'duration': item.get('contentDetails', {}).get('duration'),
    'views': item.get('statistics', {}).get('viewCount'),
    'likes': item.get('statistics', {}).get('likeCount'),
    'comments': item.get('statistics', {}).get('commentCount'),
}
print(video_info)

The fields the script keeps are the monitoring set.

The response carries more than the script keeps. snippet holds the title, channel, publication date, tags, category, and the thumbnail set. statistics held 1,815,658,215 views, 19.4 million likes, and 2.46 million comments for this video on the day of the run. contentDetails says PT3M34S for the duration, hd for definition, and whether captions exist. Two shapes trip people up. duration comes as ISO 8601 text, PT3M34S here, so arithmetic on it parses that first, and every count in statistics arrives as a string, so sorting by views without int() sorts alphabetically.

YouTube Playlists Scraping

The playlistItems method pages through a playlist fifty rows at a time. Fed the uploads playlist from the channel script, it lists a channel’s entire catalogue at one unit per page:

import requests

API_KEY = 'YOUR-API-KEY'
PLAYLIST_ID = 'UUK8sQmJBp8GCxrOtXWBpyEA'

videos = []
page_token = ''
while True:
    url = ('https://www.googleapis.com/youtube/v3/playlistItems'
           f'?part=snippet,contentDetails&playlistId={PLAYLIST_ID}'
           f'&maxResults=50&pageToken={page_token}&key={API_KEY}')
    response = requests.get(url).json()
    for item in response.get('items', []):
        videos.append({
            'title': item['snippet'].get('title'),
            'video_id': item['contentDetails'].get('videoId'),
            'published_at': item['contentDetails'].get('videoPublishedAt'),
        })
    page_token = response.get('nextPageToken')
    if not page_token:
        break

print(f"{len(videos)} videos, newest: {videos[0]['title'] if videos else '-'}")

Google’s uploads playlist ran to 2,768 videos on the day of the run, which is 56 pages at fifty rows each and so 56 quota units.

The same loop walks any playlist, not only uploads, and nextPageToken is all the state it keeps.

YouTube Comments Scraping

The commentThreads method returns top-level threads, up to one hundred per page:

import json

import requests

API_KEY = 'YOUR-API-KEY'
VIDEO_ID = 'dQw4w9WgXcQ'

url = ('https://www.googleapis.com/youtube/v3/commentThreads'
       f'?part=snippet&videoId={VIDEO_ID}&maxResults=100&order=relevance&key={API_KEY}')
response = requests.get(url).json()

comments = []
for item in response.get('items', []):
    top = item['snippet']['topLevelComment']['snippet']
    comments.append({
        'author': top.get('authorDisplayName'),
        'text': top.get('textDisplay'),
        'likes': top.get('likeCount'),
        'published_at': top.get('publishedAt'),
        'replies': item['snippet'].get('totalReplyCount'),
    })

print(f"{len(comments)} threads")
print(json.dumps(comments[:2], indent=2, ensure_ascii=False))

order=relevance gives the pinned and top comments first, order=time gives the newest. Replies need a second call to the comments method with the thread ID.

Console output of the comment threads script, a hundred threads reported and the first two printed with author, text, like count, publication timestamp and reply count

One page is a hundred threads, which on a video this size is the pinned comment and 99 more, each with its own reply count in totalReplyCount. That count caps at 1000 on threads longer than that, so a busy comment section reports the same number for every one of its big threads.

YouTube Search Results Scraping

The search method is the one to spend carefully, since it has its own daily bucket of 100 calls:

import json

import requests

API_KEY = 'YOUR-API-KEY'
QUERY = 'coffee'

url = ('https://www.googleapis.com/youtube/v3/search'
       f'?part=snippet&q={QUERY}&type=video&maxResults=50&key={API_KEY}')
response = requests.get(url).json()

results = []
for item in response.get('items', []):
    results.append({
        'video_id': item['id'].get('videoId'),
        'title': item['snippet'].get('title'),
        'channel': item['snippet'].get('channelTitle'),
        'published_at': item['snippet'].get('publishedAt'),
    })

print(f"{len(results)} results")
print(json.dumps(results[:2], indent=2, ensure_ascii=False))

Every field the script keeps rides in snippet, and the video id sits one level up in id.videoId rather than beside the rest.

Console output of the search script, fifty results reported and the first two printed with video id, title, channel and publication timestamp

Fifty results is the ceiling for one call, and each further page costs another of the hundred daily searches. Titles arrive HTML-escaped, so a video with quotation marks in its name comes back carrying " and needs html.unescape before it reaches a CSV.

When a Request Fails

The API reports problems in the body, and the useful part is error.errors[0].reason. A bad key answers with HTTP 400 and badRequest, “API key not valid. Please pass a valid API key.” A spent quota answers 403 with quotaExceeded. A wrong video or channel ID fails softer than either. The request succeeds and items comes back empty, which is why every script above checks items before indexing into it. Wrapping each call in a check of response.get('error') turns all three cases into messages instead of stack traces.

Quota and Rate Limits

Past the two ceilings named at the top, the mechanics are what catch a budget out. Every request counts, an invalid one included. Every page of a paged result counts as another call. The counters reset at midnight Pacific Time, so a script that burns the search bucket at noon waits until the next day rather than the next hour.

Listing a channel through its uploads playlist instead of through search is the difference between one unit per fifty videos and a spent search call, which is why the channel script above carries uploads_playlist. The old model most tutorials still describe, where a search cost 100 units out of the shared 10,000, is gone.

Scraping YouTube Search Results and Transcripts

Both walls from the previous section have the same shape of answer, a request where the quota and the parsing sit on someone else’s side. HasData’s YouTube endpoints are four GET calls under https://api.hasdata.com/scrape/youtube/, covering search, video, channel and transcript, and each request costs 10 credits whichever one you call.

Search first, because the hundred a day is the harder wall. Paging is a token in the response rather than another line against a quota:

import requests

API_KEY = "YOUR_API_KEY"

def search(query, pages=3):
    rows, token = [], None
    for _ in range(pages):
        params = {"q": query}
        if token:
            params["paginationToken"] = token
        r = requests.get("https://api.hasdata.com/scrape/youtube/search",
                         headers={"x-api-key": API_KEY}, params=params, timeout=60)
        r.raise_for_status()
        data = r.json()
        for v in data.get("videoResults", []):
            rows.append({"videoId": v["videoId"], "title": v["title"],
                         "views": v.get("views"), "published": v.get("publishedDate")})
        token = (data.get("pagination") or {}).get("nextPageToken")
        if not token:
            break
    return rows

hits = search("web scraping python")
print(len(hits), "videos")
for h in hits[:3]:
    print(h["videoId"], h["views"], h["title"][:52])

Three pages return forty videos:

40 videos
QhD015WUMxE 482776 Beginners Guide To Web Scraping with Python - All Yo
XVv6mJpFOb0 1821783 Web Scraping with Python - Beautiful Soup Crash Cour
hHQlcnubuFI 166041 Learn Web Scraping in 5 Minutes (NO PRIOR KNOWLEDGE)

The response carries more than videoResults. adsResults, sponsoredResults, shortsResults and playlistResults come back in the same payload, which is the page as a viewer sees it. The official API has no concept of an ad slot on a results page, so that half of the SERP is invisible to it by design.

Transcripts are the other wall, and this is the one the official API cannot do at all for a video you don’t own:

r = requests.get("https://api.hasdata.com/scrape/youtube/transcript",
                 headers={"x-api-key": API_KEY},
                 params={"v": "dQw4w9WgXcQ"}, timeout=60)
d = r.json()

print(", ".join(t["languageCode"] for t in d["availableTranscripts"]))
print(len(d["transcript"]), "segments")
for seg in d["transcript"][:3]:
    print(seg["startTimeText"], seg["snippet"])

Six tracks for that video, and the default one in 52 segments:

en, en, de-DE, ja, pt-BR, es-419
52 segments
0:00 [Music]
0:18 We're no strangers to
0:21 love. You know the rules and so do

Each segment carries startMs and endMs with the text, so a timestamped index is a join over snippet values and nothing has to be aligned by hand. Pass languageCode to pick a track from availableTranscripts, and type=asr when you specifically want the auto-generated one.

Web Scraping with yt-dlp

You can use the yt-dlp library, a fork of the now-defunct youtube-dl, to download videos and audio from various websites, including YouTube. It’s actively maintained, with releases roughly monthly, and it needs no API key. Install it with pip install yt-dlp.

Three limits are worth knowing before starting. It reads video metadata well and comment threads poorly, so analytics built on comments belong on the API. It can hit CAPTCHAs when YouTube decides the traffic looks automated, and nothing in the library manages that beyond proxies. And it has no quota mechanism at all, which cuts both ways. Nothing stops a run, and nothing warns before YouTube does.

One script covers the three read patterns:

import yt_dlp

flat = {'extract_flat': True, 'quiet': True}

# one video, full metadata
with yt_dlp.YoutubeDL({'quiet': True, 'skip_download': True}) as ydl:
    video = ydl.extract_info('https://www.youtube.com/watch?v=dQw4w9WgXcQ',
                             download=False)
print(video['title'], video['view_count'], video['uploader'])

# a playlist, titles only, no per-video requests
with yt_dlp.YoutubeDL(flat) as ydl:
    playlist = ydl.extract_info(
        'https://www.youtube.com/playlist?list=UUK8sQmJBp8GCxrOtXWBpyEA',
        download=False)
print(playlist['title'], len(playlist['entries']))

# search, ten results
with yt_dlp.YoutubeDL(flat) as ydl:
    search = ydl.extract_info('ytsearch10:Coffee', download=False)
for entry in search['entries']:
    print(entry['title'], entry['url'])

extract_flat is what keeps the playlist and search cheap, since yt-dlp lists the entries without fetching each video page. The video call returns the full metadata dictionary, the uploads playlist for that channel listed 2,768 entries on the day of the run, and the search returns its ten.

Console output of the yt-dlp script, the video title with its view count and uploader, the uploads playlist with its entry count, and ten search results each followed by a watch URL

The channel route carries a trap. Pointing yt-dlp at a channel URL like youtube.com/c/Google with extract_flat returns the channel’s tabs as three nested playlists rather than videos, so point it at the uploads playlist instead, the same UU ID the API section derived.

The dictionary yt-dlp returns for one video held 74 keys. Beyond the ones the script prints, upload_date arrives as a YYYYMMDD string, duration is an integer in seconds where the API gives ISO text, tags listed 27 entries, and formats described 48 downloadable variants with codecs and bitrates, which is the layer the API has no equivalent for. When the job is picking a stream or auditing available qualities, this dictionary is the reason to reach for yt-dlp first.

Comments Without the API

For comment threads without an API key there’s a separate library, installed with pip install youtube-comment-downloader:

from youtube_comment_downloader import YoutubeCommentDownloader

video_url = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'

downloader = YoutubeCommentDownloader()
comments = []
for comment in downloader.get_comments_from_url(video_url):
    comments.append({
        'author': comment['author'],
        'text': comment['text'],
        'likes': comment['votes'],
        'time': comment['time'],
    })
    if len(comments) >= 100:
        break

print(comments[:5])

Two of the four fields arrive as display text rather than numbers. time comes back in the interface language of whatever account the request looks like, so a run can return 1 rok temu where you expected a date, and votes comes back as 316 tys. rather than 316000. The library carries time_parsed next to time, a Unix timestamp, which is the field to map when dates matter. Vote counts have no parsed twin and need the abbreviation handled.

Console output from the comment downloader, where likes come back quoted as strings and time reads "3 weeks ago" rather than a date

The library’s own README lists the rest of its fields and options, and yt-dlp’s documentation covers the extraction options from the section above.

Scraping YouTube Data Using Selenium

A browser is the route when the target is what the page renders rather than what the API exposes. The steps don’t change across pages. Open the page, wait for it to render, read the elements by CSS selector, and close the browser. Google Colaboratory can’t run a web driver, so this script runs locally. If Selenium is new, our Selenium with Python guide starts from zero.

The selectors for a video page, checked against the live page:

DevTools open on a YouTube video page with the title h1.ytd-watch-metadata, the channel name, owner-sub-count and description-inline-expander each boxed in red

How to find CSS selectors of elements

Right-click the element, pick Inspect, and the panel opens on its node. These hold on the live page:

ElementSelector
Titleh1.ytd-watch-metadata
Channel name#owner #channel-name a
Subscribers#owner-sub-count
Description#description-inline-expander
Comment threadytd-comment-thread-renderer
Comment author / text#author-text span / #content-text

Two behaviours cost the most debugging time, so the script handles both. Comments don’t exist in the DOM until the page scrolls, and a selector that matches a hidden duplicate node returns an empty string from .text, which is why the reads below go through textContent:

import time
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.chrome.options import Options
from selenium.common.exceptions import NoSuchElementException

options = Options()
options.add_argument("--headless=new")
driver = webdriver.Chrome(options=options)

url = "https://www.youtube.com/watch?v=dQw4w9WgXcQ"

def text_of(root, selector):
    try:
        el = root.find_element(By.CSS_SELECTOR, selector)
        return el.get_attribute("textContent").strip()
    except NoSuchElementException:
        return None

try:
    driver.get(url)
    time.sleep(10)

    data = {
        "title": text_of(driver, "h1.ytd-watch-metadata"),
        "channel": text_of(driver, "#owner #channel-name a"),
        "subscribers": text_of(driver, "#owner-sub-count"),
        "description": text_of(driver, "#description-inline-expander"),
    }

    # comments render only after the page scrolls
    threads = []
    for _ in range(6):
        driver.execute_script("window.scrollBy(0, 1200);")
        time.sleep(3)
        threads = driver.find_elements(By.CSS_SELECTOR, "ytd-comment-thread-renderer")
        if threads:
            break

    comments = []
    for thread in threads[:20]:
        comments.append({
            "author": text_of(thread, "#author-text span"),
            "text": text_of(thread, "#content-text"),
        })
finally:
    driver.quit()

print(data)
print(f"{len(comments)} comments, first: {comments[0] if comments else '-'}")

driver.quit() goes in finally because a Chrome left behind by an exception stays in memory, and a loop over videos leaves one per failure.

The run returns the four page fields and twenty comment threads, with the description cut here for width:

{'title': 'Rick Astley - Never Gonna Give You Up (Official Video) (4K Remaster)',
 'channel': 'Rick Astley',
 'subscribers': '4.54M subscribers',
 'description': 'The official video for "Never Gonna Give You Up" by Rick Astley...'}
20 comments, first: {'author': '@YouTube', 'text': 'can confirm: he never gave us up'}

The ten-second sleep is this script’s weakest line, and the Playwright version below is largely about removing it.

The Same Scrape in Playwright

Playwright runs the identical page logic with explicit waits instead of sleeps, which makes it both faster and steadier on slow loads:

from playwright.sync_api import sync_playwright

url = "https://www.youtube.com/watch?v=dQw4w9WgXcQ"

with sync_playwright() as p:
    browser = p.chromium.launch(headless=True)
    page = browser.new_page()
    page.goto(url, wait_until="domcontentloaded")
    page.wait_for_selector("h1.ytd-watch-metadata")

    data = {
        "title": page.locator("h1.ytd-watch-metadata").inner_text(),
        "channel": page.locator("#owner #channel-name a").first.inner_text(),
        "subscribers": page.locator("#owner-sub-count").inner_text(),
        "description": page.locator("#description-inline-expander").inner_text(),
    }

    # comments render only after the page scrolls
    page.mouse.wheel(0, 1200)
    page.wait_for_selector("ytd-comment-thread-renderer")
    comments = []
    for c in page.locator("ytd-comment-thread-renderer").all()[:20]:
        comments.append({
            "author": c.locator("#author-text span").first.inner_text(),
            "text": c.locator("#content-text").first.inner_text(),
        })
    browser.close()

print(data)
print(f"{len(comments)} comments")

Install it with pip install playwright followed by playwright install chromium. The waits are the practical difference: wait_for_selector returns the moment the element exists, where the Selenium version sleeps a fixed ten seconds whether the page needs two or twelve.

Conclusion and Takeaways

The API is the default, with stable fields, documented limits, and the one route YouTube itself supports. Its real constraint is the 100 searches a day, and the uploads-playlist trick above is how most projects live inside it. yt-dlp covers what the API meters or omits, video metadata in bulk with no key, and gets weaker exactly where the API is strong, comments and guarantees. A browser is the last resort for data only the rendered page carries, at the price of selectors that break when the layout changes.

All of the examples run in the cloud from this Google Colaboratory folder if you would rather not install Python locally.

Valentina Skakun
Valentina Skakun
Valentina is a software engineer who builds data extraction tools before writing about them. With a strong background in Python, she also leverages her experience in JavaScript, PHP, R, and Ruby to reverse-engineer complex web architectures.If data renders in a browser, she will find a way to script its extraction.
Articles

Might Be Interesting