NEWScrapingAnt MCP for Claude Code, Cursor & Windsurf — try it free →
Skip to main content

Download Files with Selenium in Python: Chrome and Firefox

· 11 min read
Oleg Kulyk
Co-Founder @ ScrapingAnt

Download Files with Selenium in Python: Chrome and Firefox

Updated 2026-09-21

Re-tested Chrome and Firefox downloads with Selenium 4.49.0 in headed and headless modes. Replaced legacy APIs and fixed sleeps with an explicit file-verification helper, and removed the broken JavaScript and Safe Browsing workaround. The runnable evidence packet includes the fixtures, captured output and failure cases.

To download a file with Selenium in Python, configure the browser's download directory before starting the session, click the link, and wait for the expected file to pass a content check before closing the browser. Waiting for the link to be clickable and waiting for the file to finish are separate steps.

Below are tested Chrome and Firefox configurations, including headless runs. The examples download a CSV, a delayed binary file and a PDF from a local fixture server. They verify the expected length and SHA-256 digest rather than trusting that a filename appeared.

Run the working download example

Use Python 3.12 and an installed Chrome browser. The Firefox example requests stable, so Selenium Manager can provision a current stable Firefox separately from an older system installation. Initial Python package and browser/driver downloads need internet access. Headed runs also need a graphical desktop.

Clone the dated packet, then create an environment:

git clone --branch selenium-download-file/2026-09-21 https://github.com/ScrapingAnt/scrapingant-examples.git
cd scrapingant-examples/examples/selenium-download-file
python3.12 -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements.txt

requirements.txt pins selenium==4.49.0. These are macOS/Linux shell commands; the recorded browser runs used Python 3.12.11 on macOS 26.6.2, not a Windows environment. The separate clean-install check used Python 3.12.10.

Run Chrome headless and retain the files:

python chrome_download.py --output generated/chrome

Use an empty or nonexistent output directory. The script creates and resolves it to an absolute path, starts its own loopback fixture server, and saves report.csv, payload.bin and sample.pdf. It prints the destination path after verification. Choose a new directory for a repeat run; an existing populated folder raises FileExistsError rather than accidentally accepting stale downloads.

The captured verification lines were:

chrome/headless: browser=153.0.8010.50
driver=153.0.8010.52
csv: report.csv bytes=14/14 sha256=bf162b0bac0059094e9754a1f179b88e05a94785b6b6c58e71478e0b9c198cab verified=True
slow: payload.bin bytes=65536/65536 sha256=7daca2095d0438260fa849183dfc67faa459fdf4936e1bc91eec6b281b27e4c2 verified=True
pdf: sample.pdf bytes=590/590 sha256=cb8d75102cc5a0a252df53b3a977db81e6bcf9bd5af093c3487d713fd5af69dc verified=True
verified=3 attempted=3

Without --output, the scripts use temporary directories and remove them on exit. Run Firefox or the full matrix with:

python firefox_download.py
./run.sh

run.sh includes both headed and headless sessions, failure cases and helper tests. It writes the command/output transcripts to expected_output/ and exits nonzero if a script fails. On a machine without a desktop, HEADLESS_ONLY=1 ./run.sh explicitly selects the smaller headless-only matrix.

Configure Chrome's download directory

This is chrome_download.py's browser factory. The runner passes it the absolute, empty directory returned by ensure_empty() below:

from selenium import webdriver


def create_driver(download_dir, headless=True):
options = webdriver.ChromeOptions()
if headless:
options.add_argument("--headless")
options.add_experimental_option("prefs", {
"download.default_directory": str(download_dir),
"download.prompt_for_download": False,
"plugins.always_open_pdf_externally": True,
})
return webdriver.Chrome(options=options)

The configuration saved all three fixture attachments in both modes. It sets Chrome's download destination and disables its download prompt; the PDF preference requests an external download instead of the built-in PDF viewer. It does not disable Safe Browsing. The tested PDF response also uses Content-Disposition: attachment—this is not a test of every site's inline PDF behavior.

ChromeDriver recommends full download paths, and warns that quitting the browser too early can terminate a download. Create the directory explicitly in Python; do not depend on a browser preference to create it. Prefer a dedicated test folder to a special system directory such as the Desktop.

The runner waits for each fixture link, then checks the downloaded bytes. This excerpt is inside the tested run() function in browser_run.py; driver, directory and case are supplied by that function:

name, size, digest = expected(case)
target = directory / name
if target.exists():
raise FileExistsError(name)
WebDriverWait(driver, 10).until(
EC.element_to_be_clickable((By.ID, case))
).click()
result = wait_for_download(target, size, digest)

WebDriverWait here waits for the element. It does not certify that the later download has completed. The enclosing runner calls driver.quit() in finally, after the file checks or a failure. See finding elements with Selenium if locating the download link is the part that is failing.

Configure Firefox downloads and PDF handling

The Firefox script uses Options.set_preference() directly:

from selenium import webdriver


def create_driver(download_dir, headless=True):
options = webdriver.FirefoxOptions()
options.browser_version = "stable"
if headless:
options.add_argument("-headless")
options.set_preference("browser.download.folderList", 2)
options.set_preference("browser.download.dir", str(download_dir))
options.set_preference("browser.download.useDownloadDir", True)
options.set_preference("browser.helperApps.neverAsk.saveToDisk",
"text/csv,application/octet-stream,application/pdf")
options.set_preference("pdfjs.disabled", True)
return webdriver.Firefox(options=options)

This combination selects a custom destination, enables using that destination and specifies the fixture MIME types. pdfjs.disabled disables the PDF viewer in this configuration. The fixture server supplies text/csv, application/octet-stream and application/pdf, together with attachment filenames and lengths.

For these responses, Firefox saved the same bytes as Chrome. The captured headless result was:

firefox/headless: browser=156.0
driver=0.37.1
csv: report.csv bytes=14/14 sha256=bf162b0bac0059094e9754a1f179b88e05a94785b6b6c58e71478e0b9c198cab verified=True
slow: payload.bin bytes=65536/65536 sha256=7daca2095d0438260fa849183dfc67faa459fdf4936e1bc91eec6b281b27e4c2 verified=True
pdf: sample.pdf bytes=590/590 sha256=cb8d75102cc5a0a252df53b3a977db81e6bcf9bd5af093c3487d713fd5af69dc verified=True
verified=3 attempted=3

These are the preferences we tested together, not a claim that every preference is necessary for every Firefox release or server response. A .pdf URL alone does not describe the response headers or viewer behavior you will encounter. Check those before changing the configuration.

options.browser_version = "stable" asks Selenium Manager for the stable browser. Its resolved version can change on a future run; the captured run used Firefox 156.0 with GeckoDriver 0.37.1. Selenium Manager also resolves and caches drivers when the code does not supply one.

Wait for a verified file, not a fixed sleep

A filename can exist without containing the expected payload. The packet reproduces that problem with a truncated CSV:

naive exists=True nonempty_directory=True bytes=3/14
truncated: TimeoutError (expected)
stale directory: FileExistsError (expected)
missing: TimeoutError (expected)
chrome interrupted HTTP body: TimeoutError (expected)
chrome remaining suffixes=[]
firefox interrupted HTTP body: TimeoutError (expected)
firefox remaining suffixes=['.bin']

The naive checks say True with only 3 of the expected 14 bytes present. In the real interrupted-transfer test, Firefox even left a .bin entry behind. The file check still timed out instead of declaring success.

Both browser scripts use the following helper from download_helpers.py:

"""Verification for a known payload in an isolated local download directory."""
import hashlib
from pathlib import Path
import time


def ensure_empty(directory):
directory = Path(directory).resolve()
directory.mkdir(parents=True, exist_ok=True)
if any(directory.iterdir()):
raise FileExistsError(f"Use an empty download directory: {directory}")
return directory


def wait_for_download(target, expected_size, expected_sha256, timeout=30, poll=0.1):
target = Path(target)
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
partials = [p for p in target.parent.iterdir()
if p.suffix in {".crdownload", ".part"}]
if target.is_file() and not partials:
try:
payload = target.read_bytes()
except FileNotFoundError: # Browser renamed a file between checks.
continue
if len(payload) == expected_size:
if hashlib.sha256(payload).hexdigest() != expected_sha256:
raise ValueError(f"Checksum mismatch: {target.name}")
return target
time.sleep(poll)
names = sorted(p.name for p in target.parent.iterdir())
raise TimeoutError(f"Not verified: {target.name}; directory entries: {names}")

Call ensure_empty() before starting the browser and clicking. It prevents a previous run's file from satisfying the next run's check. The shared runner downloads sequentially into that isolated directory and also rejects an existing target filename before each click.

wait_for_download() requires a known size and digest. For the self-authored fixtures, those values come from the original payload bytes. For your application, use an independently supplied checksum or an equivalent content-validation rule; do not compute the expected digest from the downloaded file itself. A stable file size alone cannot establish that an arbitrary download is correct.

The helper uses a monotonic deadline and checks for .crdownload and .part artifacts in the isolated directory. Those suffixes are an additional guard, not a browser-neutral completion protocol. A correctly sized file with the wrong digest raises ValueError; an absent, truncated or still-partial file reaches TimeoutError, which includes the observed directory entries.

This small-fixture implementation reads the file into memory. Use a streaming verifier for large files rather than copying that memory behavior without considering payload size. Do not share its download folder between simultaneous browser jobs.

Headless results and tested versions

Chrome uses --headless; Firefox uses -headless. To exercise the same scripts with a visible browser:

python chrome_download.py --headed
python firefox_download.py --headed

The recorded matrix used Selenium 4.49.0:

BrowserDriverModeCSVDelayed binaryPDF attachment
Chrome 153.0.8010.50ChromeDriver 153.0.8010.52HeadlessVerifiedVerifiedVerified
Chrome 153.0.8010.50ChromeDriver 153.0.8010.52HeadedVerifiedVerifiedVerified
Firefox 156.0GeckoDriver 0.37.1HeadlessVerifiedVerifiedVerified
Firefox 156.0GeckoDriver 0.37.1HeadedVerifiedVerifiedVerified

That is 12 verified downloads out of 12 attempted fixture cases, with payloads of 14 bytes, 65,536 bytes and 590 bytes respectively. Each result matched both the expected length and SHA-256 digest. This is a compatibility observation for these fixtures and versions, not a production reliability percentage or a speed comparison.

The packet also passed seven helper tests, including both temporary suffixes, a delayed rename, wrong contents, a truncated file, a missing file and a stale directory. Both headless browsers rejected the intentionally interrupted HTTP transfer through the expected timeout.

Fix common Selenium download failures

SymptomWhat to check
Output directory rejectedUse a new empty directory. The runner deliberately refuses a populated one.
Link never clickedConfirm the locator matches the page and the element becomes clickable; this fails before the file wait.
Timeout with no final fileCheck the destination, browser/session lifetime and response. The interrupted fixture demonstrates that a click can occur without a valid result.
Timeout with .part or .crdownloadInspect the transfer and the directory entries. Do not rename a partial artifact merely to make a filename test pass.
PDF opens in a viewerCheck the actual response headers and PDF viewer settings. The measured PDF case was an attachment.
Checksum mismatchInspect the returned contents; do not label a file successful because its name and length look right.
File exists on another machineThis helper inspects the Python process's local filesystem. Remote/Grid download retrieval needs a separate workflow and was not tested here.

The old article also contained APIs that do not run on the tested Selenium release. Captured examples:

Chrome executable_path: TypeError: WebDriver.__init__() got an unexpected keyword argument 'executable_path'
Firefox executable_path: TypeError: WebDriver.__init__() got an unexpected keyword argument 'executable_path'
Firefox firefox_profile: TypeError: WebDriver.__init__() got an unexpected keyword argument 'firefox_profile'
find_element_by_id: AttributeError: type object 'WebDriver' has no attribute 'find_element_by_id'
old window.open snippet: SyntaxError: unterminated string literal (detected at line 1) (old-snippet, line 1)

The replacements above use webdriver.Chrome(options=...), webdriver.Firefox(options=...), Firefox preferences on Options, and By.ID locators. They leave automatic driver resolution to Selenium Manager.

Use direct HTTP when you only need the file

If browser interaction is unnecessary, an HTTP client can retrieve a known download URL. Selenium's file-download guidance recommends passing a resolved link and any required cookies to an HTTP library when the file contents are the objective.

The packet's 04_http.py runs the same CSV case with Python's standard library. The full script starts its local server, sets a timeout, checks the response status, writes the file, verifies its bytes and exercises a missing URL. Run it with:

python 04_http.py

Captured output:

HTTP: report.csv bytes=14 sha256=bf162b0bac0059094e9754a1f179b88e05a94785b6b6c58e71478e0b9c198cab verified=True
id,name
1,Ant
missing URL: HTTPError 404 (expected)

This is an unauthenticated local example. It does not transfer a Selenium session into the HTTP client. If your real download depends on authentication, treat cookie/header transfer as a separate requirement; it is not tested by this packet.

Scope and next steps

ScrapingAnt is not needed for these local browser tests or the directly accessible fixture download. If your next task is page acquisition, the ScrapingAnt request/response documentation is a separate starting point. This article does not demonstrate an API fetching arbitrary binary files or controlling a local Selenium download directory.

The evidence covers local macOS Chrome and Firefox sessions with small, known attachments. Windows, Linux, containers, Grid, authenticated downloads, blob URLs, inline PDF pages and concurrent jobs were not tested. The default full matrix requires a graphical desktop and is not enabled in the repository's generic monthly CI workflow.

For a different browser automation library, see downloading files with Playwright or Puppeteer downloads.

Examples tested on 2026-09-21 with Python 3.12.11 and Selenium 4.49.0; exact browser and driver versions are listed above. Code: Selenium download evidence packet.

This article was drafted with AI assistance from a tested evidence packet and reviewed by the named author, who is responsible for the code, measurements and corrections.

Forget about getting blocked while scraping the Web

Try out ScrapingAnt Web Scraping API with thousands of proxy servers and an entire headless Chrome cluster