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 on some edges a missing size comes back as a gray 120x90 placeholder at HTTP 200 instead of a clean 404. 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.
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. 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.
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 either 404s or, on some CDN edges, returns a gray 120x90 placeholder at HTTP 200. A check of status_code == 200 alone passes the placeholder through, which is why the fix is to also check the Content-Length header or the decoded image width and reject 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.