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

To save PDFs that a website offers as downloads, use Playwright’s expect_download() around the action that starts each file, then call Download.save_as() to copy it to a permanent location. For separate download links, repeat that sequence for each link. The example below uses synchronous Python; async and common variations follow. This is for downloading existing files, not turning webpages into PDFs or uploading local PDFs.

Download one PDF per link with synchronous Python

Install Playwright and its browser before running the script. The example uses Chromium and a page with links whose accessible name is “Download PDF”; replace the URL and locator with the controls on your target site. The code illustrates the documented flow; it has not been tested against a particular website, whose selectors, authentication and download behavior may differ.

from pathlib import Path
from playwright.sync_api import sync_playwright

OUTPUT_DIR = Path("downloads")
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)

with sync_playwright() as p:
    browser = p.chromium.launch()
    context = browser.new_context(accept_downloads=True)
    page = context.new_page()
    page.goto("https://example.com/reports")

    links = page.get_by_role("link", name="Download PDF").all()
    for index, link in enumerate(links, start=1):
        with page.expect_download() as download_info:
            link.click()
        download = download_info.value
        destination = OUTPUT_DIR / f"report-{index}.pdf"
        download.save_as(destination)
        print(f"Saved {destination}")

    context.close()
    browser.close()

expect_download() must be registered before the click. The click may cause a navigation or reload on some sites; if that happens, locate the next control again after the navigation rather than relying on an old element reference. The numbered filenames deliberately avoid collisions and do not depend on the name suggested by the site.

Install and run

In a clean Python environment, install the Python package and Chromium browser with:

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
python -m pip install playwright
python -m playwright install chromium
python download_pdfs.py

Playwright’s official Python download guide and API describe the download event and saving workflow: Downloads guide and Download API.

Why the event must wrap the action

A download initiated by a page action emits a download event, from which Playwright provides a Download object. The object represents a browser-managed temporary download; it is not yet a durable file at the path your application chooses. In the loop, page.expect_download() waits for the event while the click triggers it, and save_as() copies the completed download to your selected destination. Saving this way also waits for the download to finish if necessary. See the Page API for the event-waiting API and the Download API for save behavior.

Keep the browser context open until each file has been saved. Playwright documents that context downloads are deleted when the context closes, so a temporary download path or a browser launch download directory is not a substitute for copying files with save_as() before cleanup. The browser API documents download acceptance behavior; the example sets accept_downloads=True explicitly so the intent is clear. See the download guide and BrowserType API.

Use async Python when the rest of the job is async

The same ordering applies in Playwright’s async API: enter the download expectation, await the click, await the resulting download object, then await its save. This complete example uses an async Playwright session and the same sample locator:

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
import asyncio
from pathlib import Path
from playwright.async_api import async_playwright

async def main():
    output_dir = Path("downloads")
    output_dir.mkdir(parents=True, exist_ok=True)

    async with async_playwright() as p:
        browser = await p.chromium.launch()
        context = await browser.new_context(accept_downloads=True)
        page = await context.new_page()
        await page.goto("https://example.com/reports")

        links = page.get_by_role("link", name="Download PDF")
        count = await links.count()
        for index in range(count):
            link = links.nth(index)
            async with page.expect_download() as download_info:
                await link.click()
            download = await download_info.value
            destination = output_dir / f"report-{index + 1}.pdf"
            await download.save_as(destination)
            print(f"Saved {destination}")

        await context.close()
        await browser.close()

asyncio.run(main())

Use the sync form for a straightforward script and the async form when it needs to integrate with an existing asyncio application. Avoid mixing sync and async Playwright APIs in one flow.

Choose filenames that stay unique and safe

Playwright exposes download.suggested_filename, which is usually derived from the response’s Content-Disposition header or an HTML download attribute. Browsers may compute suggested names differently. A site may also offer several files with the same name, so saving every item under that name can overwrite an earlier one. These behaviors are described in the Download API.

If preserving the suggested name matters, add collision handling and sanitize it before using it as a local path. Treat server-provided names as untrusted input: strip path separators and avoid allowing a name to write outside the intended output directory. One simple collision-safe approach is:

from pathlib import Path
import re

def safe_filename(name: str) -> str:
    # Keep a plain filename, not any directories supplied with it.
    name = Path(name).name
    name = re.sub(r"[^A-Za-z0-9._-]", "_", name).strip("._")
    return name or "download.pdf"

def unique_path(directory: Path, filename: str) -> Path:
    candidate = directory / filename
    stem, suffix = candidate.stem, candidate.suffix
    number = 2
    while candidate.exists():
        candidate = directory / f"{stem}-{number}{suffix}"
        number += 1
    return candidate

# Inside the download loop, after receiving `download`:
filename = safe_filename(download.suggested_filename)
destination = unique_path(OUTPUT_DIR, filename)
download.save_as(destination)

The numbered filename pattern from the first example is simpler when the site’s original filenames are not important. Whichever strategy you choose, decide explicitly whether reruns should replace old files, create new versions, or skip files already present.

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

When one click starts several downloads

If each file has its own link or button, the per-control loop is easiest to reason about: one expectation, one action and one save per file. If a single click causes the site to start multiple attachments, each attachment produces a download event, but the official guide does not provide a single batch recipe for coordinating those events. A listener can observe events, but event-handler control flow is harder to follow and can outlive the main flow; the official download guide calls out that complexity.

For this pattern, first determine the site’s actual behavior and how it signals that the batch is complete. Your orchestration must retain each download object, save each file to a distinct destination, handle any errors, and finish the saves before closing the context. Do not assume that waiting for one event means every attachment has finished. A per-click expectation is not interchangeable with a batch listener when the page’s trigger behavior differs.

Set a realistic download timeout

page.expect_download() has a default timeout of 30,000 milliseconds and accepts a timeout option and a predicate, according to the Page API. If the site genuinely takes longer to produce a file, set a longer timeout for that expectation, for example:

with page.expect_download(timeout=60_000) as download_info:
    page.get_by_role("link", name="Download PDF").click()
download = download_info.value

Increase the timeout to match observed site behavior rather than using a large value to hide a broken selector or a click that did not trigger a download.

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

Download, create and upload are different operations

  • Download an existing attachment: use expect_download() and Download.save_as(), as in the examples above.
  • Create a PDF from the open page: use page.pdf(). It generates a PDF of the current page; it does not retrieve a PDF attachment. See the Page API.
  • Upload local PDFs to a site: use a file input locator’s set_input_files() with a list of local paths. That sends files to the page rather than saving downloads. See the input guide.

Or skip the browser setup

If the goal is to capture a webpage as a PDF rather than download existing PDF attachments, ScreenshotNeo offers a one-call screenshot API that can return a PDF. It does not replace Playwright for clicking through a site’s attachment links.

For example, this cURL request captures a webpage as an image; see ScreenshotNeo’s documentation for the PDF options and API parameters:

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

ScreenshotNeo accepts cookie and consent banners before capture and removes more than 60 known consent platforms, newsletter popups and chat widgets; each step can be turned off. Bot checks and CAPTCHAs, blank pages, timeouts, failed loads and cache hits are not billed, and responses report page verdict and billing headers. It also has an MCP server with screenshot, page-info and PDF-capture tools for AI agents. The Free plan includes 1,000 shots per month without a card; paid plans start at $5 for 3,000 shots. Every feature is on every plan. See ScreenshotNeo for the service details. Sign up for 1,000 free screenshots a month, with no card required.

Troubleshoot common failures

The expectation times out

A timeout means Playwright did not observe a download event within the configured period. Check that the locator selects the intended control, that the click is not blocked by an overlay, and that the site actually initiates a file download rather than rendering a PDF in the page or opening a new tab. If the download is real but slow, increase the expectation timeout; the default is 30,000 milliseconds in the Page API.

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

The file disappears after the script ends

Save it with download.save_as() before closing the browser context. A context’s managed download files are temporary and are removed when the context closes, as the download guide explains.

Some files overwrite others

Use a unique destination per item, such as an index-based name or a collision-safe version of suggested_filename. Do not assume different links imply different filenames.

The loop works once, then fails on later links

A click may navigate or reload the page, leaving a previously collected element handle stale. Use locators, and if the page changes after a click, resolve the next locator against the current page state. Also verify that the site has not changed its controls or requires a new page state between downloads.

The downloaded content is not the PDF you expected

Confirm whether the clicked control downloads a file, displays a PDF viewer, or navigates to a PDF URL. Those are different page behaviors. If the site presents the document in the page rather than triggering a download event, expect_download() may not be the correct operation; inspect the intended flow rather than treating a rendered page PDF as an attachment.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

Practical reliability and cost considerations

For a modest list of individual links, save sequentially: it keeps the page interaction and corresponding file easy to match, and avoids closing the context while saves are pending. A larger collection may take longer because each item has to be fetched and written; the actual duration depends on the target site, file sizes, network and any login or navigation requirements. The provided Playwright documentation does not establish a universal throughput figure or a guaranteed batch strategy.

Handle failures at the level appropriate to the job. For a one-off script, letting an exception stop execution can prevent silent omissions. For a long batch, record which link failed, continue only if that is acceptable, and report incomplete results clearly. Avoid retrying blindly when a click might have succeeded but its event was missed; duplicate downloads or changed page state can complicate recovery. Keep authentication and any required cookies scoped to the browser context, and do not log sensitive credentials.

Frequently Asked Questions

Does Playwright automatically save downloads into my chosen folder?

No. Use `Download.save_as(path)` to copy a download to the durable path your script chooses.

Can a single Playwright click download multiple PDFs?

Some sites start multiple attachments from one action, but the orchestration depends on the site’s event behavior; the per-link example is for one download per trigger.

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

Does `page.pdf()` download an attachment?

No. It creates a PDF of the current page. Use the page’s download flow for existing attachment files.

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.