ThumbnailsGrabber.com

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.

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.

The URL you are fetching

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.

Download with requests

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.

Reject the placeholder by pixel width (Pillow)

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)

No dependencies: the urllib version

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}")

Get the video ID from any YouTube URL

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.

Download many videos at once

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.

When you want yt-dlp to pick the size

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.

Usage notes

Frequently asked questions

How do I download a YouTube thumbnail in Python?

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.

Why does my Python script save a tiny gray thumbnail?

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.

Do I need the YouTube Data API or an API key in Python?

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.

How do I download a thumbnail without installing requests?

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.

How do I get the video ID from a URL in Python?

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.

How do I download thumbnails for many videos at once in Python?

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.

Can I use yt-dlp from Python instead?

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.