DEV Community

Cover image for Three YouTube Data API v3 behaviors that broke my analytics classifier
MORINAGA
MORINAGA

Posted on

Three YouTube Data API v3 behaviors that broke my analytics classifier

I run a daily Python script that fetches the last 30 videos from a YouTube channel, classifies them as high or low performers by view count relative to the channel median (as of July 2026; the script has since switched to a views-per-day rate with an age-eligibility window), and writes a strategy directive telling the next day's video-generation routine which archetype to produce. I wrote about the classification logic here.

Three behaviors in the YouTube Data API v3 broke that script. Only one of them announced itself with an error, and even that error didn't say why. Each is documented if you read the reference carefully, but they're easy to miss when you're building quickly.

1. Channel handle lookup fails silently when the handle doesn't match

To look up a channel by its handle (the @name you see in YouTube URLs), you use the forHandle parameter:

GET /youtube/v3/channels?part=snippet,statistics&forHandle=myhandle&key=<key>
Enter fullscreen mode Exit fullscreen mode

The problem: when the handle doesn't match — a missing underscore, a hyphen where the real handle has none, a handle that was renamed since you wrote it down — the API returns a 200 OK with an empty items array. No error, no 404, no explanation. Your code receives valid JSON that looks like a successful response, but there are no channels in it.

My first API-key version tried exactly one handle — YT_CHANNEL_HANDLE, defaulting to claude_automate — and raised RuntimeError with the entire response body when items came back empty. That raise is the only reason this didn't turn into silent bad data. What it couldn't do was explain anything: the run died with channel not found for handle @... followed by a body that was a perfectly healthy 200 OK carrying an empty array. The error proved the request had succeeded and told me nothing about which string form the channel actually answers to, and I never did pin down the exact mismatch — I stopped trying to identify it and made the lookup stop depending on getting it right.

The fix is a cascade: try multiple handle variants (claudeautomate, claude_automate, claude-automate alongside the configured one), then fall back to a search.list query if all handle lookups return empty:

for handle in [env_handle, "channelname", "channel-name", "channel_name"]:
    result = api_get(f"channels?part=snippet&forHandle={handle}&key={key}")
    if result.get("items"):
        return result["items"][0]

# Final fallback: search by channel name
search = api_get(f"search?part=snippet&type=channel&q=channel+name&key={key}")
for item in search.get("items", []):
    channel_id = item["snippet"]["channelId"]
    result = api_get(f"channels?part=contentDetails,statistics&id={channel_id}&key={key}")
    if result.get("items"):
        return result["items"][0]
Enter fullscreen mode Exit fullscreen mode

The search fallback is slower (one extra API call) and counts against quota, but it's a last resort for a once-daily job. The real lesson is that any YouTube handle lookup needs a fallback path — there's no single canonical string form that reliably works across all channels.

The forUsername parameter is also worth knowing: it's the legacy form for older channels that predate the @handle system. If your channel predates 2022 or so and forHandle keeps failing, try forUsername with the channel's original username.

2. View counts require a second API call

playlistItems.list gives you the list of videos in a channel's uploads playlist. It includes video IDs, publish timestamps, titles, and descriptions. It does not include view counts, like counts, or any performance statistics.

To get viewCount, likeCount, and commentCount, you need a separate videos.list call with part=statistics:

def fetch_uploads(api_key, uploads_playlist_id):
    # Returns: videoId, publishedAt, title, description
    # Does NOT return: viewCount, likeCount, commentCount
    url = f"playlistItems?part=snippet,contentDetails&playlistId={uploads_playlist_id}&maxResults=30&key={api_key}"
    items = api_get(url).get("items", [])
    return [item["contentDetails"]["videoId"] for item in items]

def fetch_stats(api_key, video_ids):
    ids = ",".join(video_ids)  # batch up to 50 IDs per request
    url = f"videos?part=statistics,snippet&id={ids}&key={api_key}"
    return api_get(url).get("items", [])
Enter fullscreen mode Exit fullscreen mode

The videos.list endpoint lets you batch up to 50 video IDs per request as a comma-separated string. For a 30-video sample, one call gets everything.

One thing to watch: the order of items in the videos.list response is not guaranteed to match the order of IDs in the request. Don't assume stats[i] corresponds to video_ids[i]. Match by item["id"]:

stats_by_id = {v["id"]: v for v in fetch_stats(api_key, video_ids)}
ordered = [stats_by_id[vid] for vid in video_ids if vid in stats_by_id]
Enter fullscreen mode Exit fullscreen mode

The two-call minimum also means you need the contentDetails.relatedPlaylists.uploads value from the channel response (the uploads playlist ID), which is its own separate fact. Getting from "channel handle" to "view counts on the last 30 videos" requires: channels.list → playlistItems.list → videos.list. Three calls minimum. The YouTube Data API v3 videos.list reference documents which part values return which fields — statistics is the one that has view counts.

3. Including "unknown" videos corrupts the archetype ranking

After getting view counts, I join each video with its archetype — product_findindiegame, build_in_public, meta, and so on — by matching the YouTube title against titles stored in local uploaded-video JSON files. The match uses word overlap: if at least 4 content words are shared between the API title and the local title, the video gets that archetype. If no match clears the threshold, the video gets _archetype = "unknown".

The initial version kept "unknown" in the pool and picked the next day's preferred archetype by frequency — the most common archetype among the high performers. Here's the archetype table from the 2026-06-20 report, the one that made the problem obvious:

archetype        |  n | median_views
-----------------|----|-------------
unknown          | 22 | 19
contrarian       |  2 | 15
curated          |  1 | 10
build_in_public  |  1 | 55
meta             |  1 | 18
ai_tools         |  1 | 7
technical        |  1 | 67
recap            |  1 | 1
Enter fullscreen mode Exit fullscreen mode

unknown wasn't the best-performing bucket — technical (67) and build_in_public (55) both beat its median of 19. It was simply the biggest bucket: 22 of the 30 sampled videos, and 9 of the 11 high performers. So the frequency-based tuner emitted "Prefer: unknown." It also emitted "Avoid: unknown," because unknown dominated the low bucket too. Contradictory and meaningless guidance — "unknown" isn't a content format you can produce, and while the tuner was chasing it, genuinely weak formats (a recap video sitting at 1 view) kept getting generated.

The reason "unknown" swallowed the sample: it collected the older videos that predated the automated pipeline (no uploaded JSON file exists for them) and recent videos whose archetype file never got written, because the publish workflow had been failing on artifact quota. Neither group says anything about which format to produce tomorrow.

The fix was two changes in one commit: rank archetypes by median views instead of by how often they show up in the high bucket, and drop "unknown" from the ranking entirely:

for video in videos:
    archetype = video.get("_archetype", "unknown")
    if archetype == "unknown":
        continue  # skip — no actionable signal
    arch_views[archetype].append(int(video["statistics"]["viewCount"]))

ranked = sorted(arch_views.items(), key=lambda kv: statistics.median(kv[1]), reverse=True)
Enter fullscreen mode Exit fullscreen mode

After exclusion, the rankings only reflect videos where I know the production context. "Prefer product_findindiegame" is actionable. "Prefer unknown" is noise.


The same pattern comes up in any classifier built on fuzzy join data: always define your null category explicitly, and decide upfront whether it should participate in ranking or be excluded. Null-inclusive rankings silently inflate or deflate whichever real category happens to share data with the null bucket.


Part of an ongoing 6-month experiment running three AI-curated directory sites. The technical claims here are real; this article was AI-assisted.

Top comments (0)