
How to Take a Screenshot of a Website in Python: Every Method Compared

Snapshot Site Team
04 Aug 2026 - 10 Mins read
There is no single Python library that takes screenshots of websites, and that is the source of most of the confusion around this question. What exists instead is a browser, and four different ways of telling one what to do. Three of them run the browser on your machine. One of them doesn't run a browser at all.
Which is right for you comes down to a question that has nothing to do with Python: do you need to drive a browser, or do you need an image? If you have to log in, fill a form, click through three steps of a wizard and then capture what's on screen, you need a browser and you should run one. If you have a URL and you want a PNG of it, a browser is a 400 MB dependency you are installing in order to use one method on it.
This page walks all four honestly, with what each costs to install, to maintain, and to run in CI.
Selenium: the oldest answer, and still the first search result
Selenium has been the answer to this question for well over a decade, which is why it still tops the results page. It is a genuinely mature project and the setup story is much better than its reputation suggests — since Selenium 4.6, Selenium Manager resolves and downloads the matching driver binary for you, so the era of manually pinning chromedriver to your Chrome version is over.
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
options = Options()
options.add_argument("--headless=new")
options.add_argument("--window-size=1440,900")
driver = webdriver.Chrome(options=options)
try:
driver.get("https://example.com")
driver.save_screenshot("viewport.png")
finally:
driver.quit()
Two details in there matter. --headless=new asks explicitly for the modern headless mode; on older Chrome builds the bare --headless flag selected a separate legacy binary that rendered noticeably differently from a visible browser, and that difference caught a lot of people out. Old headless was removed in Chrome 132, so on any current build the two spellings are the same thing — writing =new is simply explicit and costs nothing. And --window-size is how you set the capture dimensions, because there is no viewport argument on the screenshot call itself.
Then you hit the problem this article exists to warn you about: save_screenshot() captures the viewport, not the page. That file is 1440×900 no matter how long the page is. The W3C WebDriver spec defines Take Screenshot as the viewport, not the document.
There are three ways out, and you should know all of them because two of them are traps:
Chrome DevTools Protocol. On Chromium-based browsers you can bypass WebDriver and ask the browser directly:
import base64
result = driver.execute_cdp_cmd(
"Page.captureScreenshot",
{"format": "png", "captureBeyondViewport": True},
)
with open("full.png", "wb") as handle:
handle.write(base64.b64decode(result["data"]))
This works well, with one caveat worth knowing before you debug it: on some Chrome builds captureBeyondViewport will not exceed the viewport on its own, and you need to pass an explicit clip region or call Emulation.setDeviceMetricsOverride with the full document height first. It is also, by definition, not portable — execute_cdp_cmd does not exist on the Firefox or Safari drivers, so the moment you claim cross-browser coverage you have two code paths.
Firefox's native support. Geckodriver does implement a full-page command, exposed as driver.save_full_page_screenshot("full.png"). Firefox only. If your grid is Firefox-based this is the cleanest option available in any of these libraries.
Stitching. Scroll the page in viewport-height increments, capture each one, paste them together with Pillow. Every tutorial that recommends this understates what happens next: sticky headers get duplicated into every seam, position: fixed elements repeat down the whole image, lazy-loaded content pops in mid-scroll and shifts the offsets, and scroll-linked animations fire at different points on each pass. The seam artefacts are a well-understood class of failure — why sticky headers break full-page screenshots covers what actually happens and why fixing it in the stitcher is the wrong layer. Avoid stitching unless you have no other option.
Selenium's real strength is elsewhere: it is the standard for cross-browser functional testing, it has bindings in every language your organisation uses, and the ecosystem around Selenium Grid is enormous. It is a browser-testing tool that can take a screenshot, not a screenshot tool.
Playwright for Python: the best self-hosted answer today
If you are starting a new project in 2026 and you want to drive a browser from Python, use Playwright. It is genuinely excellent — the API is coherent, the auto-waiting removes most of the sleep-and-hope code that Selenium scripts accumulate, and full-page capture is a single keyword argument that works identically on Chromium, Firefox and WebKit.
pip install playwright
playwright install chromium
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page(viewport={"width": 1440, "height": 900})
page.goto("https://example.com", wait_until="networkidle")
page.screenshot(path="full.png", full_page=True)
browser.close()
That's the whole thing. No CDP escape hatch, no stitching, no per-browser branch. Playwright also gives you page.locator(".card").screenshot() for capturing a single element, clip for a fixed region, a mask argument that paints over the locators you pass it, and device_scale_factor on new_page() for retina output.
Now the part the tutorials skip. playwright install does not install a Python package — it downloads browser builds. Chromium alone is a few hundred megabytes on disk; installing all three engines, which is what a bare playwright install does, lands you comfortably past a gigabyte. Those builds are versioned against the Playwright release, so pip install --upgrade playwright without a matching playwright install gives you a library that refuses to launch. In CI, that download happens on every cold build unless you cache it correctly or use the official container image, and in a Lambda-shaped deployment target the size is not an inconvenience, it is a hard wall — the 50 MB and 250 MB packaging limits are the reason compressed-Chromium layers exist at all.
There is also a headless system dependency set — fonts, graphics and audio libraries — that Playwright will install for you on Debian and Ubuntu via playwright install --with-deps, and that you have to source yourself on Alpine, RHEL derivatives, or anything unusual. A missing font package is not an error; it is a screenshot where Japanese or Arabic text renders as boxes, which you will notice in review rather than in logs.
None of this is a criticism of Playwright. It is what running a real browser costs, and Playwright manages that cost better than anything else in the Python ecosystem.
pyppeteer: don't start here
pyppeteer comes up constantly in older answers, and the honest advice is to skip it. It is an unofficial community port of Puppeteer's Node API to Python — it was never maintained by the Puppeteer team — and it has been effectively unmaintained for years. Practically, that means it is pinned to a Chromium revision from a different era, it downloads that revision itself on first run, and its asyncio internals were written against Python's async API as it looked several versions ago, so you will meet event-loop and dependency-resolution errors before you meet a screenshot.
If you already have a pyppeteer script in production, the migration target is Playwright's async API, which is close enough in shape that most of the port is mechanical:
import asyncio
from playwright.async_api import async_playwright
async def capture(url: str, path: str) -> None:
async with async_playwright() as p:
browser = await p.chromium.launch()
page = await browser.new_page()
await page.goto(url, wait_until="networkidle")
await page.screenshot(path=path, full_page=True)
await browser.close()
asyncio.run(capture("https://example.com", "full.png"))
Mentioning pyppeteer at all is only useful so that you can recognise it in a search result and move on.
The fourth option: no browser at all
The other approach is to not put a browser in your Python project. You send a URL over HTTPS and get back a link to a rendered image. The only dependency is requests, which you almost certainly already have.
import os
import requests
response = requests.post(
"https://api.prod.ss.snapshot-site.com/api/v1/screenshot",
headers={
"Content-Type": "application/json",
"x-snapshotsiteapi-key": os.environ["SNAPSHOT_SITE_API_KEY"],
},
json={
"url": "https://example.com",
"format": "png",
"width": 1440,
"fullSize": True,
"hideCookie": True,
"delay": 2,
},
timeout=60,
)
response.raise_for_status()
link = response.json()["link"]
image = requests.get(link, timeout=60)
with open("full.png", "wb") as handle:
handle.write(image.content)
The parameters map onto the Playwright options you'd otherwise write by hand. fullSize is full_page. width sets the viewport, from 100 to 8000 pixels. delay is a settle wait, and note that it is an integer number of seconds from 0 to 10 — pass 2000 expecting milliseconds and the request comes back rejected. format accepts png, jpeg, jpg, webp and pdf, so the same call that produces an image produces a print-ready PDF by changing one string. hideCookie strips common consent banners, which is otherwise a per-site selector hunt; why cookie consent banners break screenshot automation explains why that one is worth having as a flag rather than a fixture.
The v2 endpoint adds two more, and reaching them means swapping the path in that request from /api/v1/screenshot to /api/v2/screenshot. hide takes comma-separated CSS selectors to remove before rendering — chat launchers, promo bars, anything you'd otherwise mask — and it is available on every plan including the free tier. javascriptCode runs your own script in the page before capture, which covers most of what you'd have written inside page.evaluate(), and it is available on Ultra and above. If you're unsure which endpoint you want, v1 vs v2 vs v3 lays out the split.
What this does not do is make rendering easy. A page that loads its hero images on scroll will still capture half-empty, and the techniques for waiting out lazy-loaded images and animations are the same techniques either way. Changing which library drives the browser does not change what the page does while you wait for it. And when a capture comes back blank, the field guide to debugging blank and broken screenshots is the same checklist you'd work through locally.
What each one actually costs
| What you install | Full page | Where it hurts | |
|---|---|---|---|
| Selenium | Library, plus a browser and driver on the host | CDP or Firefox-only, or stitching | Viewport-only default; two code paths for cross-browser full page |
| Playwright | Library, plus browser builds (hundreds of MB to over 1 GB) | full_page=True, all engines | Download size, browser/library version lockstep, system deps in CI |
| pyppeteer | Library, plus a self-downloaded legacy Chromium | Supported, on an old browser | Unmaintained; you inherit every unfixed bug |
| HTTP API | requests | fullSize: true | Network dependency, per-request cost, no multi-step interaction |
The line that matters most is maintenance, and it doesn't show up until month three. A local browser is a version pin you have to keep current, and the pin has two ends: the library expects a protocol the browser speaks, and neither of them waits for you. The failure is rarely dramatic — a rebuilt base image whose font package got slimmed down is enough to change what your screenshots look like without a line of your code changing.
CI is where the difference is most visible. A container that has to fetch a browser is a slow container, and the fix — caching the download, or moving to a browser-preinstalled image — is a piece of pipeline configuration you now own. A requests-only job installs in seconds on any base image.
The cost model is different in kind, not just in size. Self-hosting costs engineering attention and compute you've already paid for; an API costs per capture — €20 a month for 15,000 requests on Ultra works out to about €1.33 per 1,000 captures, and the free tier is 50 a month, which is enough to test whether the approach fits before deciding anything. Working out which side of that line your volume actually falls on is its own exercise, and the full self-hosted-versus-managed breakdown is in what a screenshot API actually costs per 1,000 captures.
Capturing a specific stack
Most of the difficulty in this job is not the library. It is the page. Client-rendered apps, animation-heavy builders and authenticated admin screens each fail in their own characteristic way, and the fixes are specific enough to be worth writing down separately.
A React dashboard is the hard case: charts paint after their data arrives, widgets lazy-load below the fold, and inner scroll containers hide content even when the page itself looks settled — capturing a React dashboard in Python works through the timing. Django's admin has the opposite problem: it renders fast but the URL matters, because a filtered or paginated list view is a different screen from the default one, and record pages with inline related-object forms are long enough that viewport capture is useless — that's covered in screenshotting a Django admin panel in Python.
Site builders have their own signature failure. Framer leans heavily on scroll-triggered motion, so a capture fired too early gets you a page mid-animation with half its sections still transparent; see capturing a Framer site in Python for the delays that settle it. Wix combines entrance effects with gallery strips that populate on scroll and its own consent banner, and screenshotting a Wix site in Python covers that combination.
One thing worth settling early regardless of stack: whether you actually want the full page. Long captures are heavier, slower, and much more prone to render artefacts, and for a lot of jobs a fixed viewport is the better record — full page vs viewport is the decision in one page.
Batch capture, and the SDK
Once you are capturing more than one URL, the shape of the problem changes from "how do I take a screenshot" to "how do I run 400 of these without breaking something." The official Python SDK exists for that layer:
pip install snapshot-site-sdk
import os
from snapshot_site import SnapshotSiteClient
client = SnapshotSiteClient(api_key=os.environ["SNAPSHOT_SITE_API_KEY"])
result = client.screenshot({
"url": "https://example.com/pricing",
"format": "png",
"width": 1440,
"fullSize": True,
"hideCookie": True,
"hide": ".intercom-launcher, #promo-bar",
})
client.download_to(result, "pricing.png")
client.screenshot() targets the v2 endpoint, so hide is available without building the request yourself, and download_to() reads the link out of the response and writes the bytes to disk in one step. It raises SnapshotSiteError with a status_code on failure, which is what you want to catch in a loop. The same client exposes analyze() and compare() — the Python SDK write-up covers where those fit in QA and reporting pipelines.
For the batch itself, a bounded thread pool is usually the whole answer:
import concurrent.futures
URLS = ["https://example.com/", "https://example.com/pricing", "https://example.com/docs"]
def capture(url: str) -> str:
result = client.screenshot({"url": url, "fullSize": True, "hideCookie": True})
return result["link"]
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool:
for url, link in zip(URLS, pool.map(capture, URLS)):
print(url, link)
Four workers is a starting point, not a recommendation — the right number depends on your plan and on how patient the sites you're capturing are. Concurrency patterns beyond rate limits covers picking it deliberately, and retries and backoff covers the transient failures a 400-URL run will absolutely produce. Note that the same bounded-pool discipline applies if you go the Playwright route; you just have browser processes and memory to budget as well as requests.
How to choose
Run a browser locally when you need more from it than a picture. Multi-step interaction, logging in and navigating, intercepting network requests, testing across three engines, capturing pages that never leave your network, or volume high enough that per-capture cost dominates everything else — in all of those cases you should own the browser, and Playwright is how you should own it.
Call an API when the image is the point. A URL in, a PNG or PDF out, running inside a service or a scheduled job or a serverless function where a 400 MB browser is the largest thing in the deployment and the only thing you'd ever have to maintain.
The trap is picking the first option out of habit and then discovering, six months in, that you have quietly become the maintainer of a browser fleet in service of one method call. If that's the shape of your problem, drop the requests.post above into your existing script and find out whether you ever needed the browser. Fifty captures a month on the Snapshot Site free tier is enough to answer that question.




