Guides · YouTube Search Scraper · Published July 23, 2026

Export YouTube search results to CSV with Python and pandas

Turn keywords into a pandas DataFrame and out to CSV: up to 50 queries in, one row per result — video, channel, playlist, or Shorts under type: all — with its canonical URL plus type-specific fields. The script requests videos, which carry duration and the audience display field (lifetime views, or a watching count on live rows). Dedupe and failure-row handling, and cost math ($0.20 per 1,000 results) — queries that return nothing cost $0.

By slvDev · Updated and technically verified July 23, 2026


The short answer: the YouTube Search Scraper takes up to 50 keywords, returns one JSON record per result — video, channel, playlist, or Shorts under type: all, each row carrying its search_query — and pandas turns that into a CSV in three lines. No YouTube Data API key or quota. $0.20 per 1,000 delivered results; a keyword that finds nothing returns a free typed row. Full script below. (The official search.listhas the richer filter surface and no documented fees, but since Google's June 2026 quota revision it lives in a 100-calls-per-day bucket — our comparison lays out both sides.)

Setup

pip install apify-client pandas

The token comes from Apify Console → Settings → API tokens (free tier works). No Google account or API key.

Keywords → DataFrame → CSV

import os
import pandas as pd
from apify_client import ApifyClient

client = ApifyClient(os.environ["APIFY_TOKEN"])

run = client.actor("apihq/youtube-search-scraper").call(
    run_input={
        "queries": ["lofi hip hop", "synthwave", "jazz for studying"],
        "type": "video",                # video | channel | playlist | all
        "maxResults": 200,              # ceiling per query, not a guarantee
        "sortBy": "relevance",
        "uploadDate": "month",
    }
)

items = list(client.dataset(run["defaultDatasetId"]).iterate_items())

results = [r for r in items if r["success"]]        # billed: $0.0002 each
misses  = [r for r in items if not r["success"]]    # free, typed

for m in misses:
    print(m.get("search_query"), "->", m["code"])    # e.g. NO_RESULTS

df = pd.DataFrame(results)
if not df.empty:
    # the same result can surface on two result pages of one query —
    # a first-hand 200-row run contained one such duplicate. Every
    # successful row carries a canonical url, whatever its type.
    df = df.drop_duplicates(subset=["search_query", "url"])
    # live results carry a concurrent "watching" count in view_count
    # instead of lifetime views (video rows; channel/playlist rows
    # don't have this column)
    if "view_count" in df.columns:
        df["is_live_row"] = df["view_count"].fillna("").str.contains("watching")
df.to_csv("search_results.csv", index=False)
print(f"{len(df)} results -> search_results.csv")

Filtering on success first matters: a keyword that returns nothing or an unusable filter value comes back as a typed success: false row (NO_RESULTS, VALIDATION_FAILED — the registry has all of them), not as an exception — and not on your bill. The dedupe line reflects an observed fact, not paranoia: in our published 200-row run, one video surfaced on two result pages and was billed twice — 200 billed rows, 199 unique videos.

One scope note: the script requests type: video, and the analysis below targets video rows. Channel and playlist rows lack duration and view_count (they carry their own fields — subscriber_text on channel rows, playlist_id on playlist rows); Shorts returned under type: all carry video_id and view_count, but no duration. The type-safety guards above are what keep a mixed all export from breaking.

Two lines of analysis to start from

# how deep each keyword actually went (YouTube sets the depth)
print(df["search_query"].value_counts())

# which channels rank across your keywords
print(df.groupby("channel_name")["search_query"].nunique().sort_values(ascending=False).head(10))

duration is an integer of seconds on video rows (absent on live streams); view_countis a display string as YouTube renders it — lifetime views ("55M views"), or a concurrent watching count on live rows, which is what the is_live_row mask separates. The most common next step is feeding video_id into the Transcript Scraper or Comments Scraper — there's an importable n8n template that chains search → transcripts in one workflow.

Cost math

  • If all three keywords deliver their full 200 rows, as in the script: $0.12 (maxResults is a ceiling — you pay per delivered result, and YouTube usually runs out of results in the low hundreds per query).
  • 50 keywords at up to 200 results each: at most $2.00 per run.
  • A weekly rank-track of 10 terms at up to 50 results each: at most $0.10 per sweep — four full weekly sweeps cost at most $0.40; a five-run calendar month costs at most $0.50.

Honest limits

  • YouTube sets the search depth, not you. Results stop when relevance runs out — usually in the low hundreds per query. maxResults is a ceiling, not a guarantee, and the video filter is shallower than an unfiltered all search.
  • Duplicates can occur across result pages. Our first-hand run delivered 200 billed rows containing 199 unique videos — one video appeared twice, one excess duplicate row. Duplicated rows are billed as delivered rows, so keep the drop_duplicates line.
  • Display strings, not integers. Audience counts come as YouTube renders them, with mixed semantics on live rows. For exact integer statistics, the official route is search.list plus videos.batchGetStats — compared here, where its filter surface wins.
  • Public results only: what a signed-out visitor sees. One row per result with what search exposes — full descriptions, like counts, and tags are out of scope.

Deploying this?

Run one input on the YouTube Search Scraper. It comes back as either a billed result row or an unbilled, typed failure row — the contract this guide builds on.

A one-item first run costs under a cent, on your existing Apify account.