File downloads are one of those things that seem simple until you try to automate them. You click a button, a file saves somewhere, done — except Playwright’s default behavior is to block downloads entirely. Nothing gets saved unless you set things up correctly.

Here’s how to do it right.


Why downloads don’t work out of the box

By default, Playwright doesn’t know where to save files and doesn’t accept them automatically. If you click a download link without any setup, the download either gets cancelled or hangs.

You need to either:

  1. Set a download path on the browser context — all downloads save there automatically
  2. Use page.expect_download() — intercept the download event and handle it manually

Both approaches work. The difference is control. Setting a path is simpler. expect_download() gives you the file’s metadata before saving and lets you choose the filename and path per download.


Requirements

  • Python 3.8+
  • Playwright installed
pip install playwright
playwright install chromium

Method 1: Set a download path on the context

The simplest approach. Every download that happens in the browser context saves to the folder you specify.

import asyncio
from playwright.async_api import async_playwright

async def main():
    async with async_playwright() as p:
        browser = await p.chromium.launch(headless=True)
        context = await browser.new_context(
            accept_downloads=True,
            downloads_path='/tmp/downloads'  # folder must exist
        )
        page = await context.new_page()

        await page.goto('https://example.com/reports')
        await page.click('#download-csv')

        # Give the download time to complete
        await page.wait_for_timeout(3000)

        await browser.close()

asyncio.run(main())

Simple, but the problem is you don’t know when the download finishes or what filename was used. For anything more than a quick script, use expect_download() instead.


This is the right way to handle downloads in most cases. You wrap the action that triggers the download in a expect_download() context, and Playwright gives you a Download object when the download starts.

import asyncio
from playwright.async_api import async_playwright

async def main():
    async with async_playwright() as p:
        browser = await p.chromium.launch(headless=True)
        context = await browser.new_context(accept_downloads=True)
        page = await context.new_page()

        await page.goto('https://example.com/reports')

        # Wrap the click that triggers the download
        async with page.expect_download() as download_info:
            await page.click('#download-csv')

        download = await download_info.value

        # Save to a specific path
        await download.save_as('/tmp/downloads/report.csv')

        print(f'Downloaded: {download.suggested_filename}')
        print(f'Saved to: /tmp/downloads/report.csv')

        await browser.close()

asyncio.run(main())

The download object has a few useful properties:

  • download.suggested_filename — the filename the server suggested
  • download.url — the URL the file was downloaded from
  • download.path() — the temporary path where Playwright stored the file before you save it

Saving with the original filename

If you want to keep the filename the server suggested, just use it directly:

import asyncio
import os
from playwright.async_api import async_playwright

SAVE_DIR = '/tmp/downloads'

async def main():
    async with async_playwright() as p:
        browser = await p.chromium.launch(headless=True)
        context = await browser.new_context(accept_downloads=True)
        page = await context.new_page()

        await page.goto('https://example.com/reports')

        async with page.expect_download() as download_info:
            await page.click('#download-csv')

        download = await download_info.value
        filename = download.suggested_filename
        save_path = os.path.join(SAVE_DIR, filename)

        await download.save_as(save_path)
        print(f'Saved: {save_path}')

        await browser.close()

asyncio.run(main())

Downloading multiple files

Sometimes you need to download a batch of files — monthly reports, invoice PDFs, export files for a list of accounts. Here’s how to handle that in a loop.

import asyncio
import os
from playwright.async_api import async_playwright

SAVE_DIR = '/tmp/downloads'
os.makedirs(SAVE_DIR, exist_ok=True)

async def main():
    async with async_playwright() as p:
        browser = await p.chromium.launch(headless=True)
        context = await browser.new_context(accept_downloads=True)
        page = await context.new_page()

        await page.goto('https://example.com/reports')

        # Get all download buttons on the page
        download_buttons = await page.locator('.download-btn').all()
        print(f'Found {len(download_buttons)} files to download')

        for i, button in enumerate(download_buttons):
            async with page.expect_download() as download_info:
                await button.click()

            download = await download_info.value
            filename = download.suggested_filename or f'file_{i+1}.csv'
            save_path = os.path.join(SAVE_DIR, filename)

            await download.save_as(save_path)
            print(f'[{i+1}/{len(download_buttons)}] Saved: {filename}')

        await browser.close()

asyncio.run(main())

Downloads that require authentication

A lot of real-world download scenarios involve logging in first. Here’s the full pattern — login, then download.

import asyncio
import os
from playwright.async_api import async_playwright

async def main():
    async with async_playwright() as p:
        browser = await p.chromium.launch(headless=True)
        context = await browser.new_context(accept_downloads=True)
        page = await context.new_page()

        # Log in first
        await page.goto('https://example.com/login')
        await page.fill('#email', 'your@email.com')
        await page.fill('#password', 'yourpassword')
        await page.click('#login-btn')
        await page.wait_for_url('**/dashboard')

        # Now download the protected file
        await page.goto('https://example.com/dashboard/exports')

        async with page.expect_download() as download_info:
            await page.click('#export-data')

        download = await download_info.value
        await download.save_as(f'/tmp/downloads/{download.suggested_filename}')

        print('Done')
        await browser.close()

asyncio.run(main())

The session cookies stay in the context, so the download request is authenticated automatically.


Handling downloads triggered by navigation

Some sites trigger a download by navigating to a URL directly — there’s no button click, the download just starts when you visit the link.

import asyncio
from playwright.async_api import async_playwright

async def main():
    async with async_playwright() as p:
        browser = await p.chromium.launch(headless=True)
        context = await browser.new_context(accept_downloads=True)
        page = await context.new_page()

        # The download starts when the page navigates to this URL
        async with page.expect_download() as download_info:
            await page.goto('https://example.com/export/data.csv')

        download = await download_info.value
        await download.save_as(f'/tmp/downloads/{download.suggested_filename}')

        await browser.close()

asyncio.run(main())

Waiting for a large download to finish

For large files, you might want to track progress or wait for completion before doing something else.

import asyncio
from playwright.async_api import async_playwright

async def main():
    async with async_playwright() as p:
        browser = await p.chromium.launch(headless=True)
        context = await browser.new_context(accept_downloads=True)
        page = await context.new_page()

        await page.goto('https://example.com/reports')

        async with page.expect_download() as download_info:
            await page.click('#download-large-file')

        download = await download_info.value

        # save_as() waits for the download to complete before returning
        save_path = f'/tmp/downloads/{download.suggested_filename}'
        await download.save_as(save_path)

        # Check if the download failed
        failure = await download.failure()
        if failure:
            print(f'Download failed: {failure}')
        else:
            print(f'Saved to: {save_path}')

        await browser.close()

asyncio.run(main())

download.failure() returns None if the download succeeded, or an error string if something went wrong. Worth checking for large files where network issues are more likely.


Downloading from a direct URL (no browser needed)

If the file URL is public and doesn’t need cookies or authentication, skip Playwright entirely and use httpx or requests. Much faster.

import httpx

def download_file(url: str, save_path: str):
    with httpx.stream('GET', url) as response:
        response.raise_for_status()
        with open(save_path, 'wb') as f:
            for chunk in response.iter_bytes(chunk_size=8192):
                f.write(chunk)
    print(f'Saved: {save_path}')

download_file('https://example.com/public/data.csv', '/tmp/data.csv')

Use Playwright for downloads that need a logged-in session, require JavaScript to generate, or are behind a button/form. For everything else, a plain HTTP request is faster and simpler.


Full example: download all invoices from a billing page

Here’s a realistic scenario — log in to a billing portal and download all invoice PDFs:

import asyncio
import os
from playwright.async_api import async_playwright

SAVE_DIR = '/tmp/invoices'
os.makedirs(SAVE_DIR, exist_ok=True)

async def download_invoices(email: str, password: str):
    async with async_playwright() as p:
        browser = await p.chromium.launch(headless=True)
        context = await browser.new_context(accept_downloads=True)
        page = await context.new_page()

        # Log in
        await page.goto('https://billing.example.com/login')
        await page.fill('[name="email"]', email)
        await page.fill('[name="password"]', password)
        await page.click('[type="submit"]')
        await page.wait_for_url('**/invoices')

        # Find all invoice download links
        invoice_links = await page.locator('a[data-type="invoice-pdf"]').all()
        print(f'Found {len(invoice_links)} invoices')

        for i, link in enumerate(invoice_links):
            invoice_id = await link.get_attribute('data-invoice-id')

            async with page.expect_download() as download_info:
                await link.click()

            download = await download_info.value
            filename = f'invoice_{invoice_id}.pdf'
            await download.save_as(os.path.join(SAVE_DIR, filename))
            print(f'[{i+1}/{len(invoice_links)}] {filename}')

        print(f'\nAll invoices saved to {SAVE_DIR}')
        await browser.close()

asyncio.run(download_invoices('your@email.com', 'yourpassword'))

Common issues

Download never starts / expect_download() times out The default timeout is 30 seconds. If the download takes longer to start, pass a longer timeout: page.expect_download(timeout=60000).

File saves but is empty or corrupted You’re probably reading the file before the download finishes. save_as() waits for completion, so make sure you await it before reading the file.

Downloaded file has a generic name like download The server didn’t send a Content-Disposition header. Use download.suggested_filename to get whatever name Playwright detected, or build your own filename from the URL.

Downloads work in headed mode but not headless Some sites check for browser signals. Try adding --disable-blink-features=AutomationControlled or switch to Patchright.