Python Screenshot: Desktop (mss, PyAutoGUI) vs Web Pages
Take a screenshot in Python: mss, PyAutoGUI and DXcam for the desktop, Playwright or an API for web pages. Tested code, measured timings and fixes.
"Python screenshot" means two different jobs. Pick the one you have:
# 1. The desktop: copy what is on the monitor right now import mss with mss.MSS() as sct: sct.shot(output='desktop.png')
# 2. A web page: load a URL in a headless browser and capture it from playwright.sync_api import sync_playwright with sync_playwright() as p: browser = p.chromium.launch() page = browser.new_page() page.goto('https://example.com') page.screenshot(path='page.png', full_page=True) browser.close()
Desktop libraries (mss, PyAutoGUI, Pillow's ImageGrab, DXcam) read pixels from a screen. They are right for bots, tests of desktop apps, screen recording and computer vision. They are wrong for web pages: they capture only what is visible, they include the browser window around the page, and they need a display, which servers do not have.
Web capture (Playwright, Selenium or an API) loads the page in its own browser. It needs no screen, captures the full page at any size, and runs on a server or in CI.
This page compares both families on one machine with the same method. If you only need web pages, our Python website screenshot guide goes deeper on that side.
Tested on 2026-10-15 on macOS 27.0.1 (Apple Silicon, built-in Retina display: 1512x982 points, 3024x1964 pixels) with Python 3.12.15, mss 10.2.0, PyAutoGUI 0.9.54 (pyscreeze 1.0.1), Pillow 12.3.0, NumPy 2.5.4 and Playwright for Python 1.64.0 (Chromium 156.0.8078.4). DXcam 0.3.0 installs on macOS but does not import (it needs Windows COM), so its code is untested. Timings are medians from one machine.
Disclosure: I build RenderScreenshot, a screenshot API for web pages. The API has nothing to offer for desktop capture; use the libraries below for that.
Desktop capture or web page capture?
| You want to capture | Use | Why |
|---|---|---|
| The whole monitor, a region, or several monitors | mss | Fast, no dependencies, works on Windows, macOS and Linux (X11) |
| The screen as part of mouse/keyboard automation | PyAutoGUI | Same package moves the mouse and finds images on screen |
| Many frames per second on Windows (games, video) | DXcam | Uses the Desktop Duplication API and returns NumPy arrays |
| One quick capture, Pillow already installed | PIL.ImageGrab |
No extra package |
| A web page, full length, on a server or in CI | Playwright | Headless, no display, waits for the page |
| Many web pages, or no browser to maintain | A screenshot API | One HTTP call per page |
Desktop capture with mss
Install a pinned version. The class name changed: in mss 10.2, mss.mss() prints a DeprecationWarning, and mss.MSS() is the current name.
pip install "mss==10.2.0" pillow numpy
The first block on this page saves the first monitor. For anything more, use grab(), which returns raw pixels you can save, crop or convert:
import mss import mss.tools import numpy as np from PIL import Image with mss.MSS() as sct: # monitors[0] is the union of all screens; 1..n are the individual screens for i, mon in enumerate(sct.monitors[1:], start=1): shot = sct.grab(mon) mss.tools.to_png(shot.rgb, shot.size, output=f'monitor-{i}.png') # A region, in screen coordinates region = {'left': 100, 'top': 100, 'width': 640, 'height': 360} shot = sct.grab(region) # To Pillow: mss returns BGRA bytes img = Image.frombytes('RGB', shot.size, shot.bgra, 'raw', 'BGRX') img.save('region.webp', quality=80) # To NumPy for OpenCV or analysis: shape (height, width, 4), BGRA order arr = np.array(shot) print(arr.shape, arr.dtype) # (360, 640, 4) uint8
We ran this script with warnings turned into errors (python -W error::DeprecationWarning). It printed (360, 640, 4) uint8.
Things to know about mss:
- Retina Macs return points, not pixels. mss passes the "nominal resolution" option to macOS, so our 3024x1964 screen came back as 1512x982. PyAutoGUI and ImageGrab returned the full 3024x1964. If you need every pixel on a Mac, that difference decides the library.
- Pixels are BGRA. Code that treats
shot.rawas RGB gets swapped red and blue channels. Useshot.rgbor theBGRXdecoder shown above. - Reuse one
MSS()instance in a loop. Creating it per frame adds setup cost each time. - Linux needs X11. mss reads
$DISPLAY. Without it you getScreenShotError: $DISPLAY not set.(or an XCB connection error). On a server, start a virtual display such as Xvfb first. A pure Wayland session without XWayland gives mss no X server to read from.
Desktop capture with PyAutoGUI
PyAutoGUI is an automation library. Its screenshot function exists so that scripts can look at the screen before they click.
pip install "pyautogui==0.9.54"
import pyautogui img = pyautogui.screenshot() # a Pillow Image img.save('screen.png') print(img.size, pyautogui.size()) # pixels vs. points on a Retina Mac
On our Mac this printed (3024, 1964) Size(width=1512, height=982): the image is in pixels, but pyautogui.size() and all mouse coordinates are in points. If you find something in the screenshot and click it, divide the image coordinates by 2 on a Retina display.
PyAutoGUI delegates capture to pyscreeze, which calls Pillow's ImageGrab. On macOS, ImageGrab.grab(bbox=...) runs the system screencapture -R command. In our run, the region form pyautogui.screenshot(region=(0, 0, 800, 600)) failed with could not create image from rect and a CalledProcessError, while the full-screen form worked. If you see that, capture the full screen and crop it with Pillow, or use mss for regions.
Desktop capture with Pillow's ImageGrab
If Pillow is already a dependency, you need nothing else:
from PIL import ImageGrab ImageGrab.grab().save('screen.png')
It is the same code path that PyAutoGUI uses, with the same speed and the same Retina behavior. On Linux, ImageGrab also needs an X11 display.
Desktop capture with DXcam (Windows only)
DXcam uses the Windows Desktop Duplication API. It is built for continuous capture at high frame rates and returns NumPy arrays. We could not run it: version 0.3.0 installs on macOS but fails on import with a Windows COM error. The code below follows the docstrings in DXcam 0.3.0 and is untested.
# Windows only. pip install dxcam pillow import dxcam from PIL import Image with dxcam.create(output_color='RGB') as cam: # new_frame_only=False returns the last frame even if the screen did not change frame = cam.grab(region=(0, 0, 1280, 720), new_frame_only=False) Image.fromarray(frame).save('region.png') # Continuous capture into a ring buffer cam.start(target_fps=60) try: for _ in range(120): frame = cam.get_latest_frame() # process the frame here (OpenCV, a model, a video encoder) finally: cam.stop()
Two DXcam details that trip people up: grab() returns None when no new frame is available (that is the reason for new_frame_only=False above), and the region is (left, top, right, bottom), not (left, top, width, height) as in PyAutoGUI.
Measured: desktop libraries side by side
Method: each call ran once to warm up, then 15 times; the table shows the median. The machine was idle with normal windows open. No images were kept.
| Call | Median time | Output size |
|---|---|---|
mss.MSS().grab(monitor) |
17.2 ms | 1512x982 (points) |
mss grab of an 800x600 region |
12.9 ms | 800x600 |
mss full grab converted to a Pillow image |
33.7 ms | 1512x982 |
pyautogui.screenshot() |
419.8 ms | 3024x1964 (pixels) |
PIL.ImageGrab.grab() |
420.8 ms | 3024x1964 |
pyautogui.screenshot(region=...) |
failed (see above) | n/a |
dxcam |
not run (Windows only) | n/a |
mss was about 24 times faster than PyAutoGUI here, but it also copied a quarter of the pixels. For a fair "same pixels" comparison on a Retina Mac you would need the full-resolution path, and that is the slow one. The full-screen PNG from mss was 2,004,250 bytes, so save as WebP or JPEG when you store many captures.
For continuous capture, we ran sct.grab() in a tight loop for 3 seconds with one MSS() instance. It reached 60 frames per second for the full monitor and 78 for a 640x360 region. The full-screen figure equals the display refresh rate, so treat it as a ceiling on this Mac, not the library's limit. On Windows, DXcam's start(target_fps=...) is the tool built for this job.
Desktop capture on a Linux server
If you really need a desktop capture on a machine without a monitor (for example, a GUI app under test in CI), give it a virtual X display. With xvfb-run from the xvfb package:
sudo apt-get install -y xvfb xvfb-run --server-args="-screen 0 1920x1080x24" python capture_desktop.py
mss and PyAutoGUI then see a 1920x1080 screen. We did not run this on Linux for this page. For web pages, skip it and use a headless browser.
Why desktop capture is the wrong tool for web pages
A tempting shortcut is a script that opens a browser with webbrowser.open() or Selenium in a visible window, waits, and calls pyautogui.screenshot(). It breaks for predictable reasons:
- Only the visible part is captured. There is no full-page mode, because the desktop has no idea the page scrolls.
- The image includes browser chrome, other windows, notifications and the mouse cursor.
- It needs a display. On a server or in CI you must run Xvfb and a full browser.
- Timing is guesswork. A desktop library cannot know when the page has finished loading.
- The output size depends on the monitor, so captures differ between your laptop and a colleague's.
A headless browser fixes all five.
Web page capture with Playwright for Python
pip install "playwright==1.64.0" playwright install chromium # on Linux servers: playwright install --with-deps chromium
This script sets the viewport and pixel ratio, waits for the network to settle, fails on HTTP errors and always closes the browser:
"""python web_playwright.py https://example.com example.png""" import sys from playwright.sync_api import sync_playwright def capture(url: str, path: str) -> None: with sync_playwright() as p: browser = p.chromium.launch() try: context = browser.new_context(viewport={'width': 1280, 'height': 720}, device_scale_factor=2) page = context.new_page() response = page.goto(url, wait_until='networkidle', timeout=30_000) if response is None or not response.ok: raise RuntimeError(f'HTTP {response.status if response else "no response"} for {url}') page.screenshot(path=path, full_page=True) finally: browser.close() if __name__ == '__main__': url = sys.argv[1] if len(sys.argv) > 1 else 'https://example.com' out = sys.argv[2] if len(sys.argv) > 2 else 'page.png' try: capture(url, out) except Exception as exc: print(f'capture failed: {exc}', file=sys.stderr) sys.exit(1) print(f'saved {out}')
Results: example.com gave a 2560x1550 PNG (full page at 2x). https://example.com/nope printed capture failed: HTTP 404 for https://example.com/nope and exited with code 1. Note that device_scale_factor belongs on the context, not on page.screenshot().
Timing, 5 runs each on example.com at 1280x720:
| Pattern | Median |
|---|---|
| Launch Chromium, capture, close (cold) | 470 ms |
| Reuse one browser, new page per capture (warm) | 262 ms |
So a single web capture costs about as much as one PyAutoGUI desktop grab on this Mac, but the browser launch is most of it. Keep one browser open when you capture many pages:
"""Capture several pages concurrently with one browser (async API).""" import asyncio import sys from urllib.parse import urlparse from playwright.async_api import async_playwright URLS = ['https://example.com', 'https://example.org', 'https://example.net'] CONCURRENCY = 3 async def capture(browser, sem, url): async with sem: page = await browser.new_page(viewport={'width': 1280, 'height': 720}) try: await page.goto(url, wait_until='load', timeout=30_000) path = f'{urlparse(url).hostname}.png' await page.screenshot(path=path, full_page=True) return path finally: await page.close() async def main(): sem = asyncio.Semaphore(CONCURRENCY) async with async_playwright() as p: browser = await p.chromium.launch() try: results = await asyncio.gather(*(capture(browser, sem, u) for u in URLS), return_exceptions=True) finally: await browser.close() failed = [(u, r) for u, r in zip(URLS, results) if isinstance(r, Exception)] for url, err in failed: print(f'{url}: {err}', file=sys.stderr) print(f'{len(URLS) - len(failed)} saved, {len(failed)} failed') return 1 if failed else 0 if __name__ == '__main__': sys.exit(asyncio.run(main()))
It printed 3 saved, 0 failed. return_exceptions=True keeps one bad URL from cancelling the others, and the exit code still reports the failure. For waits, cookie banners and authentication in Playwright, see Playwright screenshots in Python. If your project already uses Selenium, Selenium screenshots in Python covers its full-page workarounds.
Web page capture with an API
A headless browser on your own server means Chromium updates, system fonts, memory limits and zombie processes. With an API, the browser runs elsewhere and Python only makes an HTTP request. This is the same capture as the Playwright script above, with our Python SDK:
pip install git+https://github.com/Render-Screenshot/rs-python.git
import os from renderscreenshot import Client, TakeOptions with Client(os.environ['RS_API_KEY']) as client: image = client.take( TakeOptions.url('https://example.com') .width(1280) .height(720) .scale(2) .full_page() .wait_for('networkidle') .block_cookie_banners() ) with open('page.png', 'wb') as f: f.write(image)
The wait options (load, domcontentloaded, networkidle, a selector) are described in the wait parameters reference. For hundreds of URLs, see the bulk screenshots guide and the batch endpoint. You can get a free API key and run the snippet above as is.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
DeprecationWarning: mss.mss is deprecated |
Class renamed in mss 10.x | Use mss.MSS() |
| mss image is half the expected size on a Mac | mss asks for nominal (point) resolution on Retina | Accept points, or use ImageGrab/PyAutoGUI for full pixels |
| Red and blue swapped after mss | Raw data is BGRA | Use shot.rgb or Image.frombytes(..., 'raw', 'BGRX') |
ScreenShotError: $DISPLAY not set. |
No X11 display (server, container, SSH) | Run under Xvfb, or use a headless browser for web pages |
| macOS capture shows only the wallpaper and empty windows | The terminal or IDE lacks Screen Recording permission | Allow it in System Settings, Privacy and Security, Screen Recording, then restart the app |
pyautogui.screenshot(region=...) raises CalledProcessError on macOS |
screencapture -R failed (it did in our run) |
Capture the full screen and crop with Pillow, or use mss |
| Clicks land in the wrong place after locating an image | Screenshot is in pixels, mouse is in points (Retina) | Divide image coordinates by the scale factor |
DXcam grab() returns None |
No new frame since the last grab | Pass new_frame_only=False, or use start() and get_latest_frame() |
| Web page capture cut off at the window height | Desktop capture, or no full_page=True |
Use Playwright with full_page=True |
Executable doesn't exist from Playwright |
Browsers not installed for this Playwright version | Run playwright install chromium after each upgrade |
| Playwright fails on Linux with missing libraries | System dependencies not installed | playwright install --with-deps chromium |
Related guides
- Website screenshots with Python: the parent guide for web page capture in Python.
- Python screenshot API: SDK setup and framework examples.
- Bulk screenshots with Python: many URLs, batches and retries.