Download a YouTube Thumbnail in Python
Just need the image? Paste a link above to grab every size in one click. Writing a script? The Python below fetches the same file, with the fallback that keeps you off the gray placeholder.
Just need the image? Paste a link above to grab every size in one click. Writing a script? The Python below fetches the same file, with the fallback that keeps you off the gray placeholder.
A YouTube thumbnail is a public static image, so downloading one in Python is a single HTTP request, no API key, no scraper, no browser. The catch is the same one every script hits: maxresdefault.jpg, the HD file, does not exist for every video, and a missing size comes back as an HTTP 404 whose body is still a gray 120x90 placeholder JPEG, so code that ignores the status saves the placeholder. This page gives a ready-to-paste function that falls back through the sizes and rejects that placeholder, a urllib version with no dependencies, a URL parser for the video ID, and a batch loop. If you only want the file and not the code, the YouTube thumbnail grabber returns every size in one click.
Every snippet below downloads the same predictable address. Take the 11-character video ID from the link and drop it into the pattern:
https://i.ytimg.com/vi/<VIDEO_ID>/maxresdefault.jpg HD 1280x720, HD uploads only
https://i.ytimg.com/vi/<VIDEO_ID>/sddefault.jpg 640x480, most videos
https://i.ytimg.com/vi/<VIDEO_ID>/hqdefault.jpg 480x360, every video
The img.youtube.com and i.ytimg.com hosts serve the same files. For the complete list of files and when each one exists, see the YouTube thumbnail URL guide. Vimeo works differently: there is no predictable image address, so a script asks the oEmbed endpoint for thumbnail_url first, as the Vimeo thumbnail downloader page documents.
The short version most tutorials show fetches maxresdefault and checks the status code. That is the bug: for a video without an HD source the placeholder returns 200, so a status check alone saves a 120x90 gray square. Check the size as well. This function tries the three sizes in order and returns the first real one:
import requests
SIZES = ("maxresdefault", "sddefault", "hqdefault")
def thumbnail_url(video_id: str) -> str:
"""Return the URL of the best real thumbnail, skipping the placeholder."""
for name in SIZES:
url = f"https://i.ytimg.com/vi/{video_id}/{name}.jpg"
r = requests.head(url, timeout=5)
# A missing size 404s; the gray placeholder is 200 but only ~1KB.
if r.status_code == 200 and int(r.headers.get("content-length", 0)) > 2048:
return url
# hqdefault always exists as a real image, so it is a safe last resort.
return f"https://i.ytimg.com/vi/{video_id}/hqdefault.jpg"
def download_thumbnail(video_id: str, path: str = None) -> str:
path = path or f"{video_id}.jpg"
r = requests.get(thumbnail_url(video_id), timeout=10)
r.raise_for_status()
with open(path, "wb") as f:
f.write(r.content)
return path
print(download_thumbnail("dQw4w9WgXcQ")) # -> dQw4w9WgXcQ.jpg
The HEAD request reads only the headers, so the fallback costs almost nothing before the one real GET. Because maxresdefault at 1280x720 sits at the top of the chain, it is the sharpest a thumbnail comes in; there is no 1080p or 4K file to request, as YouTube thumbnail resolution explains.
If you would rather verify the actual image than trust Content-Length, decode it with Pillow and check the width. The real placeholder is exactly 120 pixels wide, so anything wider is genuine:
from io import BytesIO
from PIL import Image
import requests
def is_real(image_bytes: bytes) -> bool:
return Image.open(BytesIO(image_bytes)).width > 121
r = requests.get("https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg", timeout=10)
if r.status_code == 200 and is_real(r.content):
open("thumb.jpg", "wb").write(r.content)
If you cannot pip install requests, the standard library does the same job. urllib.request fetches the bytes and raises HTTPError on a 404, and the length check skips the placeholder:
from urllib.request import urlopen, Request
from urllib.error import HTTPError
def download(video_id: str, path: str = None) -> str:
path = path or f"{video_id}.jpg"
for name in ("maxresdefault", "sddefault", "hqdefault"):
url = f"https://i.ytimg.com/vi/{video_id}/{name}.jpg"
try:
with urlopen(Request(url), timeout=10) as resp:
data = resp.read()
except HTTPError:
continue
if len(data) > 2048: # reject the 120x90 placeholder
with open(path, "wb") as f:
f.write(data)
return path
raise RuntimeError(f"No usable thumbnail for {video_id}")
You rarely start with a bare ID, so parse it out of whatever link you have, watch, youtu.be, Shorts, embed, or live, with urllib.parse:
from urllib.parse import urlparse, parse_qs
def video_id(url_or_id: str) -> str:
if len(url_or_id) == 11 and "/" not in url_or_id:
return url_or_id # already an ID
u = urlparse(url_or_id)
if u.hostname in ("youtu.be",):
return u.path.lstrip("/")[:11] # youtu.be/<id>
if u.path == "/watch":
return parse_qs(u.query).get("v", [""])[0][:11]
parts = [p for p in u.path.split("/") if p] # /shorts/, /embed/, /live/
return parts[-1][:11] if parts else ""
print(video_id("https://www.youtube.com/watch?v=dQw4w9WgXcQ")) # dQw4w9WgXcQ
print(video_id("https://youtu.be/dQw4w9WgXcQ")) # dQw4w9WgXcQ
print(video_id("https://www.youtube.com/shorts/tPEE9ZwTmy0")) # tPEE9ZwTmy0
Feed the result to download_thumbnail above and you can hand the script a raw link. To get a channel's 15 newest IDs without the Data API, fetch its RSS feed from Python (the YouTube RSS feed generator writes the URL; each entry has a yt:videoId element) and loop over them with the batch code below. For every URL shape and the reasons the ID is always 11 characters, see how to find a YouTube thumbnail.
Put the IDs in a list and loop, catching errors so one bad ID does not stop the run:
ids = ["dQw4w9WgXcQ", "9bZkp7q19f0", "kJQP7kiw5Fk"]
for vid in ids:
try:
print("saved", download_thumbnail(vid))
except Exception as e:
print("skip", vid, e)
No script at hand? The bulk YouTube thumbnail downloader takes a list of links in the browser and returns every image with no code. Prefer the shell to Python? The same fetch as curl, wget, and yt-dlp commands covers the terminal route.
If you already depend on yt-dlp, it resolves the best thumbnail a video actually has, so you never guess about maxresdefault:
from yt_dlp import YoutubeDL
with YoutubeDL({"skip_download": True, "quiet": True}) as ydl:
info = ydl.extract_info("https://www.youtube.com/watch?v=dQw4w9WgXcQ", download=False)
print(info["thumbnail"]) # best single URL
# info["thumbnails"] is the full list, smallest to largest
That is a heavier dependency than requests for one image, so reach for it when yt-dlp is already in the project. The same extract_info call also lists the seek-bar preview frames as storyboard formats, which a plain requests download cannot reach because they sit behind a signed URL; YouTube storyboard images explains why.
User-Agent and a timeout on every request, and cache the files on your own side for production traffic rather than fetching i.ytimg.com on each page load.Send a GET request to https://i.ytimg.com/vi/VIDEO_ID/maxresdefault.jpg with requests or the standard-library urllib and write the bytes to a .jpg file. Because maxresdefault does not exist for every video, fall back to sddefault then hqdefault, and reject any response smaller than about 2KB so you do not save the gray 120x90 placeholder.
For videos without an HD source or custom thumbnail, maxresdefault.jpg returns an HTTP 404 whose body is still a gray 120x90 placeholder JPEG (1,014 of 1,014 missing files in our 8,664-video study). Code that saves the response body without checking status_code writes that placeholder to disk, which is why the fix is to check the status and also the Content-Length header or the decoded image width, rejecting anything under about 2KB or 121 pixels wide.
No. Thumbnail files are public static images on i.ytimg.com, so a plain requests or urllib call reaches them with no API key, OAuth token, or quota. You only need the YouTube Data API (videos.list part=snippet returns snippet.thumbnails) when you also want metadata such as the title or view count, or a guaranteed list of which sizes exist.
Use urllib from the standard library: urllib.request.urlopen fetches the same URL with no pip install. Read the bytes, check the length is over about 2KB to skip the placeholder, and write them to a file. It is a few lines longer than requests but needs no dependencies.
Parse it with urllib.parse.urlparse: read parse_qs(query)['v'] for a watch URL, the path for a youtu.be link, and the last path segment for /shorts/, /embed/, and /live/ URLs. The ID is always the first 11 characters of that value.
Put the video IDs in a list and loop over them, calling your download function for each and catching errors so one bad ID does not stop the run. If you would rather not run a script, paste a list of links into the bulk YouTube thumbnail downloader and get every image in one pass.
Yes. Import yt_dlp and call YoutubeDL({'skip_download': True}).extract_info(url, download=False); the returned dict has a 'thumbnail' key with the best URL and a 'thumbnails' list of all of them. It resolves the highest thumbnail the video actually has, so you never guess about maxresdefault, at the cost of a heavier dependency than requests.
Prefer not to write any code? Paste any link into the YouTube thumbnail grabber and download every size in one click.