Sorting a channel by "Most popular" is the default way people study YouTube competitors. It is also the least useful one. A video with 400,000 views tells you almost nothing if the channel gets 350,000 on every upload. A video with 40,000 views on a channel that usually gets 3,000 tells you a lot: something about that topic or packaging pulled in viewers the channel normally can't reach.
That second video is an outlier, and finding outliers is a small, satisfying data problem. In this post we'll build a script that takes a channel ID, pulls its recent uploads, and scores every video against the channel's own baseline. About 70 lines of Python, standard library plus requests.
What we're calculating
The outlier score is simple:
outlier_score = video_views / baseline_views
The interesting part is the baseline. Three decisions matter:
- Median, not mean. One old viral hit drags the mean up and makes every other upload look like a failure. The median ignores a single spike.
- Same format only. Shorts and long videos live in different view ranges. Mixing them in one baseline breaks the score for both.
- Leave the video out of its own baseline. Otherwise a huge video pulls the median towards itself and shrinks its own score.
I'll also add a floor, so a channel with a 40-view median doesn't turn a 400-view upload into a "10x breakout".
Step 1: Get an API key
Create a project in the Google Cloud console, enable YouTube Data API v3, and create an API key. The getting started guide covers it in a few minutes.
Quota is the thing to keep in mind. The default allowance is 10,000 units per day, and every call we use here (channels.list, playlistItems.list, videos.list) costs 1 unit. Scoring one channel's last 50 uploads costs about 3 units, so you can run this a lot before hitting a wall. Avoid search.list for this job: it costs 100 units per call.
pip install requests
export YT_API_KEY="your-key-here"
Step 2: Find the uploads playlist
Every channel has a hidden "uploads" playlist that contains all its public videos in reverse chronological order. Reading it through playlistItems.list is far cheaper than searching.
import os
import re
import statistics
import sys
import requests
API = "https://www.googleapis.com/youtube/v3"
KEY = os.environ["YT_API_KEY"]
def get(endpoint, **params):
params["key"] = KEY
r = requests.get(f"{API}/{endpoint}", params=params, timeout=20)
r.raise_for_status()
return r.json()
def uploads_playlist(channel_id):
data = get("channels", part="contentDetails", id=channel_id)
return data["items"][0]["contentDetails"]["relatedPlaylists"]["uploads"]
def recent_video_ids(playlist_id, limit=50):
data = get("playlistItems", part="contentDetails",
playlistId=playlist_id, maxResults=min(limit, 50))
return [item["contentDetails"]["videoId"] for item in data["items"]]
Step 3: Pull views and duration
videos.list accepts up to 50 IDs per call, so one request covers everything. We need statistics.viewCount and contentDetails.duration, which comes back as an ISO 8601 string like PT12M31S.
DURATION = re.compile(r"PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?")
def seconds(iso):
match = DURATION.fullmatch(iso) # live streams can return "P0D"
if not match:
return 0
h, m, s = match.groups(default="0")
return int(h) * 3600 + int(m) * 60 + int(s)
def video_stats(video_ids):
data = get("videos", part="snippet,statistics,contentDetails",
id=",".join(video_ids))
rows = []
for v in data["items"]:
rows.append({
"id": v["id"],
"title": v["snippet"]["title"],
"published": v["snippet"]["publishedAt"][:10],
"views": int(v["statistics"].get("viewCount", 0)),
"seconds": seconds(v["contentDetails"]["duration"]),
})
return rows
A note on Shorts: the API has no "is this a Short" field. Since October 2024, Shorts can be up to 3 minutes long, so duration of 180 seconds or less is a reasonable proxy. It will misclassify the odd short horizontal video, which is fine for research purposes. If you need precision, you can request https://www.youtube.com/shorts/VIDEO_ID and check whether it redirects, but that's outside the official API.
Step 4: Score each video against its own baseline
FLOOR = 1000 # minimum baseline, protects tiny channels
MIN_SAMPLE = 5 # below this there is no fair baseline
def fmt(row):
return "short" if row["seconds"] <= 180 else "long"
def score(rows):
for row in rows:
peers = [r["views"] for r in rows
if r["id"] != row["id"] and fmt(r) == fmt(row)]
peers = peers[:30] # most recent 30 of the same format
if len(peers) < MIN_SAMPLE:
row["score"] = None
continue
baseline = max(statistics.median(peers), FLOOR)
row["baseline"] = int(baseline)
row["score"] = round(row["views"] / baseline, 1)
return rows
def main(channel_id):
playlist = uploads_playlist(channel_id)
rows = score(video_stats(recent_video_ids(playlist)))
ranked = sorted((r for r in rows if r["score"]),
key=lambda r: r["score"], reverse=True)
for r in ranked[:10]:
print(f'{r["score"]:>5}x {fmt(r):5} {r["views"]:>10,} '
f'{r["published"]} {r["title"][:60]}')
if __name__ == "__main__":
main(sys.argv[1])
Run it with any channel ID (the UC... string):
python outliers.py UCxxxxxxxxxxxxxxxxxxxxxx
You'll get the top ten videos ranked by how far they beat the channel's own normal, with format, raw views and publish date next to each score.
Reading the scores
Here's the rough scale I use:
| Score | What it usually means |
|---|---|
| Under 2x | Normal variance. Not a signal on its own. |
| 2x to 5x | Above normal. Worth noting the topic and title angle. |
| 5x to 10x | Strong breakout. The idea reached beyond the existing audience. |
| 10x and up | Rare. Study the title, thumbnail and timing closely. |
Two cautions before you act on a number:
- Age matters. A video published yesterday hasn't collected its views yet, and a five-year-old video had years to compound. Filtering to uploads older than 7 days and comparing within a similar age window makes the ranking fairer.
- One outlier is an anecdote. When the same topic shows up as a 3x or better outlier on several unrelated channels in a niche, that's real demand. Run the script across 10 or 20 channels in a niche and group results by keywords in the title, and patterns appear quickly.
Where to take it next
A few extensions that are worth the effort:
-
Pagination. Follow
nextPageTokeninplaylistItems.listto go deeper than 50 uploads. - Views per day. Divide views by days since publish before scoring, so recent videos aren't penalised.
- Caching. Store responses in SQLite keyed by video ID and date. View counts change slowly enough that a daily refresh is plenty, and your quota lasts much longer.
- Niche runs. Feed in a list of channel IDs and write every scored row to a CSV, then pivot on title keywords.
If you just want to sanity check a single video without setting up a key, I work on TubeMind, and its free YouTube outlier calculator uses this same method (median of the last 30 same-format uploads, the video left out, a 1,000-view floor), so it's handy for spot checks against your script's output.
The core idea is the part worth keeping, whatever tooling you use: judge every video against its own channel, not against the whole of YouTube. That one change turns a channel page from a popularity list into a map of what actually resonated.
Disclosure: I'm part of the team behind TubeMind, which I mention above. The code in this post is standalone and doesn't depend on it.
Top comments (0)