How to Download Images from URLs with Python

Downloading images from a list of URLs sounds easy until you do it at scale.

You start running into:

  • timeouts and flaky connections
  • random HTML error pages saved as .jpg
  • duplicate images under different URLs
  • rate limits (429) and blocks (403)

This guide answers the keyword download images from urls with python with a production-ready approach.

We will build a downloader that:

  • streams images to disk
  • validates content type and basic file signatures
  • uses retries with backoff
  • downloads concurrently
  • dedupes with SHA-256
  • optionally routes through a ProxiesAPI proxy
When hosts rate-limit image downloads, ProxiesAPI helps

Bulk image downloads trigger throttling fast. ProxiesAPI gives you a proxy endpoint you can plug into requests so your downloader keeps moving even when a host starts returning 429 or 403.


Setup

python3 -m venv .venv
source .venv/bin/activate
pip install requests tenacity

Optional proxy environment variable:

export PROXIESAPI_PROXY_URL="http://USER:PASS@gateway.proxiesapi.com:PORT"

Core design

A reliable downloader does these things:

  1. sets timeouts on every request
  2. retries transient failures
  3. streams bytes with iter_content()
  4. validates that the response is really an image
  5. generates deterministic filenames
  6. dedupes by file content, not by URL

That is the difference between a toy script and a downloader you can trust overnight.


The downloader

from __future__ import annotations

import hashlib
import mimetypes
import os
import re
import threading
from concurrent.futures import ThreadPoolExecutor, as_completed
from urllib.parse import urlparse

import requests
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential

TIMEOUT = (10, 60)
PROXIESAPI_PROXY_URL = os.getenv("PROXIESAPI_PROXY_URL")

session = requests.Session()

HEADERS = {
    "User-Agent": (
        "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
        "AppleWebKit/537.36 (KHTML, like Gecko) "
        "Chrome/123.0.0.0 Safari/537.36"
    ),
    "Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
}


def proxies_dict():
    if not PROXIESAPI_PROXY_URL:
        return None
    return {"http": PROXIESAPI_PROXY_URL, "https": PROXIESAPI_PROXY_URL}


def safe_filename(s: str, max_len: int = 120) -> str:
    s = re.sub(r"[^a-zA-Z0-9._-]+", "-", s).strip("-")
    return s[:max_len] if len(s) > max_len else s


def guess_ext(content_type: str | None, url: str) -> str:
    if content_type:
        ct = content_type.split(";")[0].strip().lower()
        ext = mimetypes.guess_extension(ct) or ""
        if ext in {".jpe", ".jpeg", ".jpg", ".png", ".webp", ".gif", ".bmp", ".tif", ".tiff"}:
            return ".jpg" if ext == ".jpeg" else ext

    path = urlparse(url).path
    _, ext = os.path.splitext(path)
    ext = ext.lower()
    if ext in {".jpg", ".jpeg", ".png", ".webp", ".gif"}:
        return ".jpg" if ext == ".jpeg" else ext

    return ".jpg"


def is_probably_image(content_type: str | None) -> bool:
    return bool(content_type and content_type.lower().startswith("image/"))


_lock = threading.Lock()


@retry(
    reraise=True,
    stop=stop_after_attempt(5),
    wait=wait_exponential(multiplier=1, min=2, max=20),
    retry=retry_if_exception_type((requests.RequestException,)),
)
def download_one(url: str, out_dir: str) -> dict:
    os.makedirs(out_dir, exist_ok=True)

    r = session.get(
        url,
        headers=HEADERS,
        timeout=TIMEOUT,
        stream=True,
        proxies=proxies_dict(),
    )

    if r.status_code in (403, 429, 500, 502, 503, 504):
        raise requests.RequestException(f"HTTP {r.status_code} for {url}")

    r.raise_for_status()

    content_type = r.headers.get("Content-Type", "")
    if not is_probably_image(content_type):
        raise ValueError(f"Non-image content-type '{content_type}' for {url}")

    ext = guess_ext(content_type, url)
    parsed = urlparse(url)
    base = safe_filename(os.path.basename(parsed.path) or "image")
    if not base:
        base = "image"

    tmp_path = os.path.join(out_dir, f"{base}.partial{ext}")

    h = hashlib.sha256()
    size = 0

    with open(tmp_path, "wb") as f:
        for chunk in r.iter_content(chunk_size=1024 * 64):
            if not chunk:
                continue
            f.write(chunk)
            h.update(chunk)
            size += len(chunk)

    digest = h.hexdigest()
    final_path = os.path.join(out_dir, f"{digest}{ext}")

    with _lock:
        if os.path.exists(final_path):
            os.remove(tmp_path)
            return {"url": url, "path": final_path, "bytes": size, "sha256": digest, "deduped": True}
        os.replace(tmp_path, final_path)

    return {"url": url, "path": final_path, "bytes": size, "sha256": digest, "deduped": False}

Download a whole list concurrently

def download_all(urls: list[str], out_dir: str = "images", workers: int = 12) -> list[dict]:
    results: list[dict] = []

    with ThreadPoolExecutor(max_workers=workers) as ex:
        futs = {ex.submit(download_one, u, out_dir): u for u in urls}

        for fut in as_completed(futs):
            url = futs[fut]
            try:
                res = fut.result()
                results.append(res)
                print("OK", url, res["path"], "deduped=" + str(res["deduped"]))
            except Exception as e:
                results.append({"url": url, "error": str(e)})
                print("ERR", url, str(e))

    return results


if __name__ == "__main__":
    urls = [
        "https://httpbin.org/image/jpeg",
        "https://httpbin.org/image/png",
    ]

    out = download_all(urls, out_dir="downloaded_images", workers=8)
    ok = [r for r in out if "error" not in r]
    print("done", "ok=", len(ok), "total=", len(out))

This structure is usually enough for tens of thousands of images without moving to asyncio.


Why SHA-256 dedupe beats filename dedupe

Two different URLs can point to the same image bytes:

  • CDN variants with different query strings
  • the same photo attached to multiple records
  • redirecting image endpoints

Filename-based dedupe misses those. Hash-based dedupe does not.

If you still need a source mapping, write a sidecar CSV:

import csv


def write_manifest(rows: list[dict], path: str = "manifest.csv") -> None:
    with open(path, "w", newline="", encoding="utf-8") as fh:
        writer = csv.DictWriter(fh, fieldnames=["url", "path", "bytes", "sha256", "deduped", "error"])
        writer.writeheader()
        for row in rows:
            writer.writerow(row)

Comparison table: threads vs asyncio

Featurerequests + threadsaiohttp
SimplicityEasyMedium
Good enough for 10k imagesYesYes
HTTP/2 supportNoSometimes
Backpressure handlingManualBetter

If you just want a dependable image downloader fast, start with requests plus a ThreadPoolExecutor. Move to aiohttp only when you actually need tighter control.


Practical advice

  • Always validate Content-Type. Error pages saved as .jpg are a silent-data-corruption bug.
  • Keep worker counts reasonable. More concurrency is not always more throughput.
  • Retry only transient failures. A persistent 404 is not a retry problem.
  • Route through ProxiesAPI only when the host starts rate-limiting or IP blocking you.

If your goal is to download images from URLs with Python reliably, the winning pattern is simple: stream, validate, retry, hash, and keep the network layer separate from the file-writing logic.

When hosts rate-limit image downloads, ProxiesAPI helps

Bulk image downloads trigger throttling fast. ProxiesAPI gives you a proxy endpoint you can plug into requests so your downloader keeps moving even when a host starts returning 429 or 403.

Related guides

How to Download Images from URLs with Python
A production-grade image downloader in Python: concurrency, retries, content-type validation, safe filenames, and checksum dedupe. Optional ProxiesAPI proxy support for rate-limited hosts.
guide#python#images#download
Web Scraping with Python: The Complete 2026 Tutorial
A from-scratch, production-minded guide to web scraping in Python: requests + BeautifulSoup, pagination, retries, caching, proxies, and a reusable scraper template.
guide#web scraping python#python#web-scraping
Async Web Scraping in Python: asyncio + aiohttp Guide (Patterns That Don’t Get You Banned)
A practical asyncio + aiohttp guide for web scraping: bounded concurrency, semaphores, retries with backoff, timeouts, per-host limits, and batch exporting. Includes a complete working template.
guide#python#asyncio#aiohttp
Web Scraping Pagination: 7 Patterns That Don’t Break (Offset, Cursor, Infinite Scroll)
A practical playbook for reliable pagination: offset vs cursor, next-page discovery, infinite scroll, duplicate prevention, and retry/backoff patterns you can copy into production.
guide#web-scraping#pagination#python