ScreenshotNeo

BlogScreenshots on your device

How to Capture Screenshots on Wayland With Python

Use XDG Desktop Portal for portable Wayland screenshots, or grim and slurp for wlroots. Includes Python code, troubleshooting, and automation options.

By the ScreenshotNeo team1 October 202611 min read

Short answer: use the XDG Desktop Portal Screenshot API when your Python application must work across desktops or inside a sandbox. The portal asks the compositor to capture the whole screen, a window, an area, or the active window. On wlroots compositors such as Sway and compatible Hyprland setups, grim is the simplest direct command-line backend, and slurp adds interactive region selection.

Wayland does not provide the unrestricted screen-reading behavior that many X11 screenshot libraries expect. The compositor or a desktop portal must authorize the capture. An X11-only grabber can therefore return a blank image, fail under native Wayland, or capture only an XWayland surface.

Choose the capture path

Approach Best for Targets Trade-offs
XDG Desktop Portal Cross-desktop applications, Flatpak and other sandboxed programs Screen, window, area and active window, subject to portal support Requires D-Bus and a portal backend; the user may see a permission or selection dialog
grim Scripts on wlroots-based compositors Whole output, or a geometry supplied with -g Requires compositor support for wlr-screencopy-unstable-v1
grim + slurp Interactive region capture on Sway and compatible compositors User-selected rectangle Compositor-specific external dependencies
pyscreenshot Applications that want a Python abstraction Depends on the selected backend Still depends on portal, GNOME Shell, grim or another supported backend
Low-level Wayland bindings Protocol experiments and specialized compositor clients Only what the implemented protocol supports More protocol and compositor-version work; bindings do not provide a portable screenshot API by themselves

For a reusable desktop application, start with the portal. For a short Sway-style automation script, use grim and slurp. Do not silently switch to an X11 capture library when a portal request is denied or cancelled.

Prerequisites and session checks

Check that Python is running inside a Wayland session and identify the available backend:

echo "$XDG_SESSION_TYPE"
echo "$XDG_CURRENT_DESKTOP"
command -v grim || true
command -v slurp || true
command -v gdbus || true

XDG_SESSION_TYPE should normally be wayland. A Wayland session alone does not guarantee that the compositor implements the wlroots screencopy protocol, nor that every portal target is available.

Option 1: Capture with the XDG Desktop Portal

The Screenshot portal is the most portable choice for desktop-neutral or sandboxed software. Its documented interface lets an application request a screenshot through D-Bus; the compositor mediates permission and selection. The portal documentation describes it as a simple way for sandboxed applications to request a screenshot. See the XDG Desktop Portal Screenshot interface documentation cited in the research dossier as [c001] and [c002].

Install the runtime pieces

You need:

  • A running user D-Bus session.
  • xdg-desktop-portal and a desktop-specific backend such as the one supplied for GNOME, KDE or wlroots environments.
  • Python 3 and the dbus-next package.
python -m pip install dbus-next

Install the portal packages through your distribution. Package names vary, so use your distribution’s package manager and verify that the user service is running before debugging Python.

Complete asynchronous Python example

The following program requests a screenshot, waits for the portal’s response signal, decodes the returned file URI and copies the result to an explicit PNG path. The portal can display a permission or selection UI, and the user can cancel it.

#!/usr/bin/env python3
import asyncio
import os
import shutil
import sys
from pathlib import Path
from urllib.parse import unquote, urlparse

from dbus_next import BusType, Message, MessageType, Variant
from dbus_next.aio import MessageBus

PORTAL = "org.freedesktop.portal.Desktop"
SCREENSHOT = "org.freedesktop.portal.Screenshot"
OBJECT = "/org/freedesktop/portal/desktop"


def option_dict():
    # The portal accepts a{sv}. An empty dictionary lets the desktop choose
    # its normal interaction. Add supported options for your desktop/backend.
    return {}


async def take_screenshot(destination: Path) -> None:
    bus = await MessageBus(bus_type=BusType.SESSION).connect()
    sender = bus.unique_name.replace(":", "").replace(".", "_")
    token = f"python_{os.getpid()}"
    handle_path = f"/org/freedesktop/portal/desktop/request/{sender}/{token}"
    response_future = asyncio.get_running_loop().create_future()

    def on_message(message):
        if (
            message.message_type == MessageType.SIGNAL
            and message.path == handle_path
            and message.interface == "org.freedesktop.portal.Request"
            and message.member == "Response"
        ):
            if not response_future.done():
                response_future.set_result(message.body)

    bus.add_message_handler(on_message)

    # Screenshot(a{sv}) -> object path
    options = {"handle_token": Variant("s", token)}
    reply = await bus.call(Message(
        destination=PORTAL,
        path=OBJECT,
        interface=SCREENSHOT,
        member="Screenshot",
        signature="a{sv}",
        body=[options],
    ))
    if reply.message_type == MessageType.ERROR:
        raise RuntimeError(f"portal request failed: {reply.error_name}: {reply.body}")

    returned_handle = reply.body[0]
    if returned_handle != handle_path:
        # Some portal implementations return an equivalent generated path.
        handle_path = returned_handle

    response = await response_future
    response_code, results = response
    if response_code != 0:
        raise RuntimeError("screenshot request was cancelled or denied")

    uri_variant = results.get("uri")
    if uri_variant is None:
        raise RuntimeError("portal returned no screenshot URI")
    uri = uri_variant.value
    parsed = urlparse(uri)
    if parsed.scheme != "file":
        raise RuntimeError(f"portal returned an unsupported URI: {uri}")

    source = Path(unquote(parsed.path))
    if not source.is_file():
        raise RuntimeError(f"portal file does not exist: {source}")
    destination.parent.mkdir(parents=True, exist_ok=True)
    shutil.copyfile(source, destination)
    print(destination)
    bus.disconnect()


if __name__ == "__main__":
    output = Path(sys.argv[1]) if len(sys.argv) > 1 else Path.home() / "Pictures" / "wayland-portal.png"
    try:
        asyncio.run(take_screenshot(output))
    except KeyboardInterrupt:
        print("screenshot cancelled", file=sys.stderr)
        raise SystemExit(130)
    except Exception as exc:
        print(f"screenshot failed: {exc}", file=sys.stderr)
        raise SystemExit(1)

Run it from a graphical user session:

python portal_screenshot.py "$HOME/Pictures/wayland-portal.png"

Portal option support is backend-dependent. Treat the returned response code as a user cancellation or denial, not as a reason to fall back silently to X11.

Portal behavior and target selection

The portal’s documented target concepts include the entire screen, a user-selected window, a selected area and the active window. The exact option keys and which targets appear in the chooser depend on the portal version and desktop backend. Keep the request minimal first, then add documented options supported by the deployment you ship.

Option 2: Use grim for a full-screen capture

grim is a small Wayland screenshot utility for compositors supporting wlr-screencopy-unstable-v1. Its project documentation and related tooling describe it as a wlroots-oriented path. A full-screen Python wrapper can validate dependencies, create the destination directory and check the subprocess result:

#!/usr/bin/env python3
import shutil
import subprocess
import sys
from pathlib import Path


def main():
    output = Path(sys.argv[1]) if len(sys.argv) > 1 else Path.home() / "Pictures" / "wayland-shot.png"
    if shutil.which("grim") is None:
        raise RuntimeError("grim is not installed or is not on PATH")
    output.parent.mkdir(parents=True, exist_ok=True)
    result = subprocess.run(["grim", str(output)], text=True, capture_output=True)
    if result.returncode != 0:
        detail = result.stderr.strip() or result.stdout.strip() or "unknown grim error"
        raise RuntimeError(detail)
    if not output.is_file() or output.stat().st_size == 0:
        raise RuntimeError("grim reported success but produced no image")
    print(output)


if __name__ == "__main__":
    try:
        main()
    except Exception as exc:
        print(f"screenshot failed: {exc}", file=sys.stderr)
        raise SystemExit(1)
python grim_full.py "$HOME/Pictures/full.png"

Option 3: Select a region with grim and slurp

slurp prints a geometry chosen by the user. Pass that geometry to grim’s -g option:

import shutil
import subprocess
from pathlib import Path


def capture_region(output: Path):
    for executable in ("slurp", "grim"):
        if shutil.which(executable) is None:
            raise RuntimeError(f"{executable} is required")

    output.parent.mkdir(parents=True, exist_ok=True)
    selected = subprocess.run(
        ["slurp"], check=False, text=True, capture_output=True
    )
    if selected.returncode != 0:
        raise RuntimeError("region selection was cancelled")
    geometry = selected.stdout.strip()
    if not geometry:
        raise RuntimeError("slurp returned an empty region")

    shot = subprocess.run(
        ["grim", "-g", geometry, str(output)],
        check=False, text=True, capture_output=True
    )
    if shot.returncode != 0:
        raise RuntimeError(shot.stderr.strip() or "grim failed")
    if not output.is_file() or output.stat().st_size == 0:
        raise RuntimeError("no screenshot was written")


capture_region(Path.home() / "Pictures" / "wayland-region.png")

This direct route is compositor-specific. If grim reports that the screencopy protocol is unavailable, use the portal when your desktop provides one.

Option 4: Use pyscreenshot as a Python abstraction

pyscreenshot can select Wayland-capable backends including XDG Desktop Portal through D-Bus, GNOME Shell Screenshot and grim, according to its project documentation. The backend still determines whether capture works on your compositor.

python -m pip install pyscreenshot
import pyscreenshot as ImageGrab

image = ImageGrab.grab()
image.save("wayland-pyscreenshot.png")

For predictable deployments, select and document the backend rather than assuming that the library can provide the same targets on every desktop. A Wayland session does not make Xwayland an equivalent replacement for compositor-authorized capture.

Output formats, paths and image validation

  • Create the parent directory before invoking the capture command.
  • Use absolute paths when a service, cron job or GUI launcher may have a different working directory.
  • Check both the subprocess exit code and the output file size.
  • PNG is the safest default for lossless desktop content. Convert after capture if you need JPEG or WebP.
  • Do not treat a zero-byte file or a tiny placeholder as success.
from PIL import Image

with Image.open("wayland-shot.png") as image:
    print(image.format, image.size, image.mode)

Sandboxing and application design

A sandboxed application generally cannot read compositor buffers directly. Give it D-Bus access to the desktop portal and include the appropriate portal dependency in the runtime. Keep permission and selection prompts visible to the user. If the request is denied or cancelled, report that state clearly and let the user retry.

For long-running services, do not assume a graphical session exists. A system service without a user D-Bus session cannot use the normal portal flow. Detect the absence of DBUS_SESSION_BUS_ADDRESS or a failed session connection and return an actionable error.

Performance and reliability

  • Portal: startup and user interaction add latency, but the compositor controls the capture and permissions consistently across desktops.
  • grim: avoids a portal dialog and is concise for automation, but only works where the compositor exposes the required wlroots protocol.
  • Region selection: slurp requires an interactive user and should not be used in a headless job.
  • Repeated captures: keep the D-Bus connection alive when using the portal, and avoid launching a new Python interpreter for every frame if your application needs a sequence.
  • Concurrency: serialize interactive requests unless your desktop explicitly supports multiple prompts. Give each portal request a unique handle token.
  • Headless environments: neither a portal nor grim can capture a display that is not running. Use a virtual compositor only when that is part of your deployment design.

Troubleshooting

Symptom Likely cause Fix
Blank image from Pillow or ImageGrab The library is using an X11 grabber under native Wayland Use the portal, grim, or a pyscreenshot backend that explicitly uses one of them
grim: compositor does not support... The compositor lacks wlr-screencopy-unstable-v1 Use the portal backend or a compositor-supported capture mechanism
grim is not found Runtime executable is not installed or PATH differs in the launcher Install grim and log the absolute executable path; do not rely on a shell alias
slurp exits non-zero The user pressed Escape or the selection UI could not open Report cancellation separately from a capture failure and allow retry
Portal D-Bus connection fails No user session bus or portal service/backend Run inside the graphical user session and install/enable xdg-desktop-portal plus a matching backend
Portal request denied User or desktop policy rejected the request Show a clear cancellation/denial message; do not silently use X11
Portal returns no URI Backend returned an unexpected response or the request was incomplete Log the response code and result dictionary, then verify portal/backend versions
Works in a terminal but not from a service Different environment, DISPLAY/WAYLAND_DISPLAY or D-Bus session Run as the graphical user, pass the session environment, and verify access to the user bus
Only an XWayland window is captured The application is using an X11 backend Switch to compositor-mediated portal or grim capture

Testing checklist

  1. Confirm XDG_SESSION_TYPE=wayland.
  2. Test a full-screen capture.
  3. Test a region and cancel the selection once.
  4. Test portal denial/cancellation and verify that your program returns a useful status.
  5. Run from the same launcher or service environment used in production.
  6. Open the resulting file and validate its dimensions and non-zero size.
  7. Repeat on each compositor and desktop backend you claim to support.

Or skip the browser setup

If your goal is a screenshot of a public web page rather than the local Wayland desktop, ScreenshotNeo removes the browser and compositor setup. It accepts one GET request and returns PNG, JPEG, WebP or PDF. Cookie and consent banners, newsletter popups and chat widgets are removed before the shot. Bot checks, blank pages and failed loads are never billed, and the response identifies the verdict and billing status in headers. Its MCP server lets Claude, Cursor and other MCP clients call take_screenshot, get_page_info and capture_pdf.

See the ScreenshotNeo API documentation for all options.

cURL

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp

Python

import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)

Node.js

const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot failed: ${res.status}`);
const fs = await import('node:fs/promises');
await fs.writeFile('shot.webp', Buffer.from(await res.arrayBuffer()));

ScreenshotNeo includes full-page capture with lazy images loaded, CSS selector element capture, device presets and custom viewports, dark mode, retina scale, custom CSS and JavaScript, waits, headers, cookies, user agents, authorization, blocking rules, caching, signed links, asynchronous jobs, webhooks, bulk capture and a usage API. The Free plan includes 1,000 screenshots each month with no card; paid plans start at $5 for 3,000. Create a free ScreenshotNeo account.

FAQ

Can Python capture a Wayland screen without user interaction?

grim can do so on a compatible wlroots compositor. The portal may require a permission or selection interaction depending on the desktop policy.

Is Xwayland a reliable screenshot fallback?

No. Xwayland does not grant an X11 application unrestricted access to the native Wayland desktop. Use a portal or compositor-supported capture path.

Should I use pywayland for screenshots?

Only when you are implementing a specialized protocol client. The bindings expose Wayland protocol machinery, but they do not automatically provide a portable screenshot implementation.

Why does the same script work on Sway but fail on another compositor?

grim depends on the compositor exposing the wlroots screencopy protocol. Use the portal for broader desktop coverage, and verify the backend on every supported environment.

Can I use this approach in a container?

Only if the container can access the graphical user session, its D-Bus session bus and the required Wayland or portal sockets. A headless container has no desktop image to capture.