Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Some links on this page are affiliate links: if you buy through them we may earn a commission, at no extra cost to you.

Download the watermark with aiohttp, open the PDF with PyMuPDF, and insert the image on every page with Page.insert_image(). Set overlay=False to put it behind existing text, reuse the returned image cross-reference (xref) for repeated pages, and save to a new file. Use await response.read() for small images; stream chunks to a temporary file when the image could be large.

Install the libraries

Install the two runtime dependencies in the environment that will run the script:

python -m pip install aiohttp pymupdf

PyMuPDF is imported as pymupdf. The examples assume a local input PDF and a writable output directory.

Free tools Windows power users keep installed

One-click scans. No signup required.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Complete example: download and watermark every page

This version keeps the HTTP operation asynchronous, checks the response status before reading it, places the image below existing PDF content, and reuses the embedded image on subsequent pages.

import asyncio
from pathlib import Path

import aiohttp
import pymupdf


async def download_bytes(session: aiohttp.ClientSession, url: str) -> bytes:
    async with session.get(url) as response:
        response.raise_for_status()
        return await response.read()


def watermark_pdf(
    input_path: str,
    output_path: str,
    image_bytes: bytes,
    *,
    overlay: bool = False,
) -> None:
    document = pymupdf.open(input_path)
    try:
        image_xref = 0
        for page in document:
            image_xref = page.insert_image(
                page.rect,
                stream=image_bytes,
                xref=image_xref,
                overlay=overlay,
                keep_proportion=True,
            )
        document.save(output_path)
    finally:
        document.close()


async def main() -> None:
    image_url = "https://example.com/watermark.png"
    input_pdf = "input.pdf"
    output_pdf = "watermarked.pdf"

    timeout = aiohttp.ClientTimeout(total=90)
    async with aiohttp.ClientSession(timeout=timeout) as session:
        image = await download_bytes(session, image_url)

    watermark_pdf(input_pdf, output_pdf, image)


if __name__ == "__main__":
    asyncio.run(main())

The call to page.insert_image() uses the page rectangle, so the image is placed across the page area. keep_proportion=True preserves its aspect ratio; the resulting image may be letterboxed or centered depending on the source and page shape. The output is a separate PDF, leaving the input unchanged.

Choose foreground or background layering

Goal Setting Result
Keep text and drawings readable overlay=False Image is inserted behind existing page content.
Make the mark sit above the page overlay=True (the default) Image is inserted in the foreground.

A foreground watermark should normally be a PNG (or another source) that already contains transparency. PyMuPDF uses the image’s own transparency; the API does not turn an opaque bitmap into a translucent one for you. An opaque full-page foreground image can hide text.

Place a logo or stamp in a custom rectangle

Replace page.rect with a pymupdf.Rect when a full-page mark is not appropriate. Coordinates are PDF points, with the page’s coordinate system used by PyMuPDF.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
def watermark_with_logo(input_path: str, output_path: str, image_bytes: bytes) -> None:
    document = pymupdf.open(input_path)
    try:
        image_xref = 0
        for page in document:
            # Example: a 180 x 60 point logo near the lower-right corner.
            box = pymupdf.Rect(
                page.rect.x1 - 200,
                page.rect.y1 - 80,
                page.rect.x1 - 20,
                page.rect.y1 - 20,
            )
            image_xref = page.insert_image(
                box,
                stream=image_bytes,
                xref=image_xref,
                overlay=False,
                keep_proportion=True,
            )
        document.save(output_path)
    finally:
        document.close()

Adjust the rectangle for your page size and desired margins. A source image with a transparent background works well for logos and stamps.

Stream a large watermark image instead of holding it in memory

response.read() loads the complete response body. For a large asset, stream 64 KiB chunks to a temporary file and pass that filename to PyMuPDF. This limits the download buffer rather than creating one large bytes object.

import asyncio
import tempfile
from pathlib import Path

import aiohttp
import pymupdf


async def download_file(
    session: aiohttp.ClientSession,
    url: str,
    filename: Path,
) -> None:
    async with session.get(url) as response:
        response.raise_for_status()
        with filename.open("wb") as output:
            async for chunk in response.content.iter_chunked(64 * 1024):
                output.write(chunk)


def watermark_from_file(
    input_path: str,
    output_path: str,
    image_filename: str,
) -> None:
    document = pymupdf.open(input_path)
    try:
        image_xref = 0
        for page in document:
            image_xref = page.insert_image(
                page.rect,
                filename=image_filename,
                xref=image_xref,
                overlay=False,
                keep_proportion=True,
            )
        # Compression can be considered when writing the result.
        document.save(output_path, deflate=True)
    finally:
        document.close()


async def large_image_workflow() -> None:
    timeout = aiohttp.ClientTimeout(total=90)
    async with aiohttp.ClientSession(timeout=timeout) as session:
        with tempfile.TemporaryDirectory() as directory:
            image_path = Path(directory) / "watermark-image"
            await download_file(session, "https://example.com/watermark.png", image_path)
            watermark_from_file("input.pdf", "watermarked.pdf", str(image_path))


if __name__ == "__main__":
    asyncio.run(large_image_workflow())

The temporary directory is removed after the PDF has been written. Keep the file until document.save() completes because PyMuPDF still needs to read it during insertion.

Reuse one aiohttp session correctly

Create one ClientSession for a workflow and close it with async with. This reuses the session’s connection resources and avoids creating a session for every page. If several watermark URLs must be downloaded, pass the same session to each download function. Do not perform blocking PDF work inside a long-running asynchronous download loop if your application has strict event-loop latency requirements; run CPU-heavy jobs in a worker process when necessary.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Control memory, output size and repeated-page work

  • Use in-memory bytes for a small image and a streamed temporary file for a large one.
  • Pass the first insert_image() return value as x​​ref on later pages. The same embedded image data can then be reused instead of repeatedly embedding it.
  • Keep the source image at a sensible pixel size. Inserted images retain their original quality, so an unnecessarily huge source can inflate the PDF.
  • Consider deflate=True when saving, then check the resulting file size and visual quality with the viewers your users actually use.
  • Always write to a different output path. Saving over the input complicates recovery if the process is interrupted.

HTTP and PDF validation checks

raise_for_status() catches HTTP failures before an HTML error page or access-denied response is treated as an image. You should also verify that the URL is permitted to serve the asset to your server; a browser-visible image is not automatically available for server-side downloads.

After saving, open the result in the target PDF viewer and inspect pages containing text, transparency, rotations and unusual dimensions. A successful HTTP response and a successful save do not guarantee that the source bytes are a suitable image or that every viewer renders its transparency identically.

Troubleshooting

401, 403 or another HTTP error

The image server rejected the request. Check the URL, required authentication and hotlink or referrer policy. Because raise_for_status() runs before reading, the failure is explicit instead of producing a misleading PDF error.

The downloaded bytes are not an image

Some servers return an HTML login page, redirect target or bot challenge with a success status. Save the streamed response temporarily and inspect its content type and first bytes, or download a known-good image URL. Do not assume an extension proves the format.

What’s actually slowing this PC down?

Pick the symptom - the matching free tool is one click away.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

The watermark hides text

Use overlay=False for a background mark, or supply a source image containing transparency when using the default foreground layer. A large opaque image can cover selectable text even when the PDF itself is otherwise valid.

The logo is stretched or clipped

Use keep_proportion=True and design a rectangle with a similar aspect ratio to the source. A full-page rectangle is rarely the right geometry for a small logo; use a custom pymupdf.Rect.

The output PDF is unexpectedly large

Reduce the source image dimensions or encoding before insertion, reuse the xref, and try deflate=True when saving. Measure your own files; the available evidence does not establish a universal size reduction or processing-time percentage.

Later pages are slow or contain repeated image data

Initialize image_xref = 0 once, assign the return value from the first insertion, and pass that xref to subsequent calls. Ensure the same image bytes or filename are used for every page.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

The source PDF is locked or malformed

PyMuPDF may refuse to open, edit or save a protected document. Confirm that the file opens normally, handle any required password through your application’s document-access flow, and test with a repaired or unlocked copy where permitted.

Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

Or skip the browser setup

If what you actually need is a clean screenshot of a web page to use as an image asset, ScreenshotNeo returns PNG, JPEG or WebP from one request. It accepts cookie and consent banners before capture and removes more than 60 known consent platforms, newsletter popups and chat widgets; each step can be disabled. Bot checks, blank pages, timeouts, failed loads and cache hits are not billed, and response headers identify the page verdict and billing status. Its MCP server lets Claude, Cursor and other MCP clients call take_screenshot, get_page_info and capture_pdf.

For a one-call image, see the ScreenshotNeo API documentation:

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

Python and Node.js clients can use the same endpoint:

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
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}`);

The Free plan includes 1,000 screenshots each month with no card. Paid plans start at $5 for 3,000 shots; every feature is included on every plan. Create a free ScreenshotNeo account.

Frequently Asked Questions

Can I watermark only selected pages?

Yes. Replace the loop over the whole document with a loop over the page indexes you choose, and call document.load_page(index) for each selected page.

Will the watermark become permanent page content?

The inserted image is stored in the output PDF page content. It is not a removable viewer annotation, so remove or replace it by editing the PDF again.

Can aiohttp download a local file path?

No. aiohttp is for HTTP-based transfers. For a local watermark, skip the download and pass its path with PyMuPDF’s filename= parameter.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.