How to Improve PIL Performance When Taking Thousands of Screenshots
Measure capture, pixel processing, and saving separately, then optimize the stage that dominates your Pillow batch.
To improve PIL (Pillow) performance when taking thousands of screenshots, first time three stages separately: screen capture, pixel processing, and file saving. Then optimize only the stage that dominates your representative batch. Timing Image.open() alone can be misleading because Pillow may read headers without decoding raster pixels.
1. Measure the real bottleneck first
Pillow’s documentation says that it does not decode raster data until an operation needs it. Opening an image can therefore look cheap while a later resize, conversion, crop, or save performs the expensive decode. See the Pillow image reading and writing tutorial.
Record capture, processing, and saving independently. Include the first operation that forces pixel access, and describe the exact workload: Pillow and Python versions, operating system, capture backend, image mode, dimensions, output format, and whether saving is included.
from time import perf_counter
from pathlib import Path
from PIL import ImageGrab
out_dir = Path("shots")
out_dir.mkdir(exist_ok=True)
capture_seconds = process_seconds = save_seconds = 0.0
count = 100
for index in range(count):
t0 = perf_counter()
image = ImageGrab.grab()
t1 = perf_counter()
# This conversion forces pixel access when it is needed by the workflow.
processed = image.convert("RGB")
t2 = perf_counter()
processed.save(out_dir / f"shot-{index:04d}.jpg", quality=80, optimize=True)
t3 = perf_counter()
capture_seconds += t1 - t0
process_seconds += t2 - t1
save_seconds += t3 - t2
image.close()
processed.close()
print(f"capture: {capture_seconds:.3f}s")
print(f"process: {process_seconds:.3f}s")
print(f"save: {save_seconds:.3f}s")
This is a measurement harness, not a universal benchmark. Run it against representative screen sizes, content, formats, and the number of files your production job handles.
2. Capture fewer pixels when you need only part of the screen
ImageGrab.grab() captures the full screen by default. Pass bbox to capture a rectangle when the task does not require the entire display. The Pillow ImageGrab reference documents the return mode as RGBA on macOS and RGB on other platforms.
from PIL import ImageGrab
# left, top, right, bottom
region = ImageGrab.grab(bbox=(100, 100, 1380, 900))
region.save("region.png")
Check coordinates on every target environment. Retina macOS captures are documented as 2x unless scale_down=True is used. A coordinate rectangle and resulting pixel dimensions can therefore differ from what you expect.
from PIL import ImageGrab
# On supported macOS versions, request a non-2x result when appropriate.
image = ImageGrab.grab(bbox=(0, 0, 1440, 900), scale_down=True)
image.save("screen.png")
On Linux, the documented X11 fallback may use gnome-screenshot, grim, or spectacle. Make sure the selected backend is installed and available to the process.
3. Reduce image size as early as the workflow allows
If later steps need thumbnails or previews, do not carry full-size pixels through every stage. Choose an operation based on the required output and compare it on your images.
Use thumbnail() for an in-place bounding box
from PIL import Image
with Image.open("input.png") as image:
image.thumbnail((800, 800))
image.save("preview.webp", method=6)
thumbnail() preserves the aspect ratio and keeps both dimensions within the requested bounds. It changes the image in place, so save or copy it first if the original dimensions are needed later.
Use resize() when you need exact dimensions
from PIL import Image
with Image.open("input.png") as image:
smaller = image.resize((800, 450), reducing_gap=3.0)
smaller.save("fixed-size.png")
smaller.close()
reducing_gap controls the reduction strategy. It is an option to compare, not a guaranteed speed improvement for every source image or output requirement.
Use JPEG draft() only for JPEG inputs
Pillow’s JPEG documentation describes draft() as a way to request loading at one-half, one-quarter, or one-eighth size, and to convert RGB to L where applicable. It is format-specific and conditional; it does not apply to PNG screenshots.
from PIL import Image
with Image.open("source.jpg") as image:
image.draft("RGB", (800, 600))
image.load() # force the requested decode
image.save("smaller.jpg", quality=85)
For screenshots containing text or for exact-pixel comparisons, keep a lossless format such as PNG. Use JPEG or WebP only when the workflow accepts their fidelity and encoding trade-offs.
4. Avoid unnecessary conversions and copies
Every conversion can decode pixels and allocate another image. Keep the original representation when the next operation can consume it directly. Convert only when an encoder or downstream API requires a particular mode.
from PIL import Image
with Image.open("input.png") as image:
if image.mode not in ("RGB", "L"):
converted = image.convert("RGB")
try:
converted.save("output.jpg", quality=80, optimize=True)
finally:
converted.close()
else:
image.save("output.jpg", quality=80, optimize=True)
The Pillow batch tutorial demonstrates converting to RGB when needed and saving JPEG with optimize=True and a chosen quality. Treat those settings as a starting point: encoder options change run time, file size, and image fidelity. Compare representative output under your actual constraints.
5. Process screenshots incrementally to control memory
Open, process, save or consume, and release each image before moving to the next. Pillow’s file-handling documentation shows the with Image.open(...) pattern and explains when the underlying file can be closed after loading. This avoids keeping thousands of decoded images live.
from pathlib import Path
from PIL import Image
for source in Path("incoming").glob("*.png"):
destination = Path("out") / (source.stem + ".webp")
with Image.open(source) as image:
image.thumbnail((1600, 1200))
image.save(destination, format="WEBP", method=6)
For multi-frame files, handle frame iteration deliberately; their file lifetime rules differ from a single-frame image. Do not retain references to processed frames after they are written.
6. Keep decompression-bomb protection enabled
For untrusted or unexpectedly large files, preserve Pillow’s safety checks. The documentation says Pillow emits a warning above MAX_IMAGE_PIXELS and raises an error above twice that value. Do not disable the guard casually. Validate input dimensions and reject files that exceed the limits your application can safely process.
from PIL import Image
with Image.open("incoming.png") as image:
width, height = image.size
if width * height > 100_000_000:
raise ValueError("image is too large for this job")
image.load()
7. Choose output settings for the actual job
| Requirement | Practical choice | Trade-off to measure |
|---|---|---|
| Exact pixels, text, or visual diffs | PNG or another lossless representation | More bytes and potentially slower writes |
| Small previews | Downsize first, then JPEG or WebP | Lossy fidelity and encoder time |
| JPEG input that can be reduced while loading | Compare draft() |
JPEG-only behavior and format limitations |
| Original image already matches the consumer | Skip conversion and resizing | Large downstream payloads |
Measure both elapsed time and resulting file size. A setting that reduces bytes can still increase CPU time, while a faster encoder can produce files that are too large or visibly degraded.
8. A complete, bounded batch example
from pathlib import Path
from time import perf_counter
from PIL import Image, ImageGrab
COUNT = 1000
BOX = (0, 0, 1280, 800)
SIZE = (640, 400)
output = Path("shots")
output.mkdir(exist_ok=True)
capture = process = save = 0.0
for index in range(COUNT):
start = perf_counter()
source = ImageGrab.grab(bbox=BOX)
after_capture = perf_counter()
# Use this only when the consumer needs a smaller image.
result = source.resize(SIZE, reducing_gap=3.0)
after_process = perf_counter()
result.save(output / f"shot-{index:05d}.webp", format="WEBP", method=6)
after_save = perf_counter()
capture += after_capture - start
process += after_process - after_capture
save += after_save - after_process
source.close()
result.close()
print({"capture_s": capture, "process_s": process, "save_s": save})
Remove the resize when the full-size capture is required. Change the output format and encoder settings only after comparing quality, size, and elapsed time on representative screenshots.
9. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
Image.open() looks fast, but saving is slow |
Pixel decoding is lazy | Time load(), conversion, resize, and save separately. |
| Memory rises throughout the batch | Decoded images or copies remain referenced | Use context managers, close derived images, and process incrementally. |
| Capture is larger than expected on macOS | Retina capture is 2x by default | Check dimensions and use scale_down=True where supported. |
| Linux capture fails | Required screenshot backend is unavailable | Install and configure the documented backend for your environment. |
| Output looks soft or colors change | Downscaling, mode conversion, or lossy encoding | Compare lossless output, dimensions, mode, and encoder settings. |
DecompressionBombWarning or error |
Input exceeds Pillow’s pixel thresholds | Validate dimensions and reject oversized or untrusted files; keep protections enabled. |
| Batch is slow only when writing | Encoder or storage is the dominant stage | Benchmark the actual format, quality, optimization settings, and destination storage. |
10. Reliability and cost considerations
- Keep capture coordinates and expected dimensions in configuration so display scaling changes are visible.
- Log the stage timings and image metadata for each representative batch.
- Use bounded work: release each image before opening the next and avoid accumulating output objects.
- Do not report a speedup without recording the Pillow version, Python version, operating system, backend, dimensions, mode, format, and whether saving was included.
- Choose lossless output when correctness depends on exact pixels; choose lossy output only when its visual and archival limits are acceptable.
11. Or skip the browser setup
If your screenshots come from web pages rather than the local desktop, ScreenshotNeo can return a clean PNG, JPEG, WebP, or PDF from one request. The API accepts the URL and handles the browser capture:
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
See the ScreenshotNeo documentation for request options. Cookie banners, newsletter popups, and chat widgets are removed before the shot. Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed, and the response identifies the verdict and billing status in headers. An MCP server provides take_screenshot, get_page_info, and capture_pdf tools for Claude, Cursor, and other MCP clients. The free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000.
Create a free ScreenshotNeo account.
12. FAQ
Is PIL or Pillow faster for screenshots?
Pillow is the maintained package commonly used for PIL-compatible code. The useful question for a batch is which stage dominates in your environment, not a universal library ranking.
Should I always use thumbnail()?
No. Use it when a bounding box is sufficient. Use exact resizing when dimensions are prescribed, and skip both when the consumer needs original pixels.
Can draft() reduce PNG screenshots?
No. The documented reduction behavior applies to JPEG loading. Compare other formats with their own resize path.
What should I include in a performance report?
Include capture, decode or processing, and save timings plus versions, operating system, backend, dimensions, mode, format, encoder settings, and memory behavior.


