· Updated · Tutorials · By Simon Chiu

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.

Line illustration of the RenderScreenshot mascot between a desktop monitor and a browser with a snake at its feet

"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.raw as RGB gets swapped red and blue channels. Use shot.rgb or the BGRX decoder 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 get ScreenShotError: $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:

  1. Only the visible part is captured. There is no full-page mode, because the desktop has no idea the page scrolls.
  2. The image includes browser chrome, other windows, notifications and the mouse cursor.
  3. It needs a display. On a server or in CI you must run Xvfb and a full browser.
  4. Timing is guesswork. A desktop library cannot know when the page has finished loading.
  5. 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
  • 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.

Related in this topic