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 genuinely 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.