15% off your first purchase with code WELCOME15, up to 25 GB.

See pricing

Guide · Scraping practice

Web scraping with Python: requests, parsing, proxies and retries

Web scraping with Python takes four parts: an HTTP client that fetches the page (requests), a parser that turns HTML into fields (Beautiful Soup), a loop that follows pagination, and the production plumbing — timeouts, retries with backoff, a proxy and polite pacing. The script at the end of this page puts all four together in about 70 lines; each step below explains a decision you would otherwise make by accident.

Reviewed 30 Oct 2026

The short answer

  • Always pass a timeout. The requests documentation is explicit that requests do not time out unless a timeout is set.
  • Use one Session: it reuses connections and keeps cookies across requests.
  • Retry with urllib3's Retry mounted on an HTTPAdapter: exponential backoff, a list of retryable status codes including 429, and Retry-After honoured by default.
  • Parse defensively: skip incomplete records rather than guessing, and look for the JSON the page loads before parsing HTML.
  • Pass the proxy on each request; session.proxies can be overridden by environment variables.
  • Read robots.txt and the site's terms, stay on public logged-out pages and pause between requests.

Set up the project

Create a virtual environment and install two packages: python -m pip install requests beautifulsoup4. Everything else in the script comes from the standard library.

LibraryJobAlternatives
requestsSend HTTP requests, manage sessions and cookiesHTTPX (sync and async), aiohttp
beautifulsoup4Parse HTML and select elements with CSS selectorslxml directly, parsel
urllib3 RetryRetries with backoff, installed with requeststenacity, hand-written loops
csv, sqlite3Store resultsPostgreSQL, Parquet files
urllib.robotparserRead robots.txtA dedicated RFC 9309 parser

For crawls of many thousands of pages with scheduling, deduplication and pipelines built in, a framework such as Scrapy saves work. The approach below is the right size for focused jobs and for learning what a framework does for you.

Step 1: fetch a page properly

  • Use a `Session`. The requests documentation notes that a session reuses the underlying TCP connection to the same host and persists cookies — faster, and closer to how a browser behaves.
  • Always set a timeout. Without one, requests waits indefinitely. A tuple sets connect and read timeouts separately: timeout=(5, 30).
  • Identify yourself. Set a User-Agent that names your project and gives a contact. It is courteous and lets an operator reach you instead of blocking you.
  • Check the status. raise_for_status() turns 4xx and 5xx responses into exceptions so a 404 page is never parsed as data.

Step 2: parse the HTML

Beautiful Soup turns HTML into a tree you query with CSS selectors: soup.select("article.product") for every product card, select_one("h2") inside each for the name. Use get_text(strip=True) for text and read attributes like a dictionary. Turn relative links into absolute ones with urllib.parse.urljoin.

Validate as you go. If a card has no price, skip it and count it; do not write an empty or guessed value. Before writing selectors at all, compare the page source with what the browser shows: if the data is missing from the source, the page loads it from a JSON endpoint you can usually request directly. Only if that fails do you need a browser — Playwright vs Puppeteer vs Selenium compares the options.

Step 3: follow pagination and stop safely

  • Follow the page's own next link — a[rel=next] where the site uses it — rather than incrementing page numbers blindly.
  • Cap the number of pages per run so a pagination bug cannot loop forever.
  • Keep a set of URLs already visited if the site's pagination can repeat.
  • Pause between pages. One to two seconds with a little randomness is a reasonable default for a small job; the site's robots.txt or terms may ask for more.

Step 4: retries and backoff

requests does not retry failed requests by default, but it accepts urllib3's Retry through an HTTPAdapter. With backoff_factor=1, urllib3 sleeps backoff_factor × 2^(retries so far) seconds and starts backing off after the second try, so the first retry is immediate and the following ones wait about 2, 4 and 8 seconds; backoff_jitter adds randomness and backoff_max caps the wait at 120 seconds by default.

status_forcelist decides which status codes are retried — 429 and the transient 5xx codes are the usual set. respect_retry_after_header is on by default and honours Retry-After on 413, 429 and 503. Limit allowed_methods to safe methods such as GET and HEAD. For the reasoning behind each code, see handling 429 rate limits.

Step 5: add a proxy

A proxy URL with credentials goes into a proxies dictionary for both schemes. The requests documentation warns that a proxy set on session.proxies can be overridden by environment variables, so the script passes proxies= on every request. For SOCKS5, install requests[socks] and use socks5h:// if host names should be resolved on the proxy.

Use a rotating username for independent pages and a sticky one when several requests must share an IP and cookies — rotating vs sticky sessions explains the trade-off. Proxies spread a legitimate workload across addresses and let you choose the country; they do not make it acceptable to send more than a site is willing to serve. Setup details for this library are on the Python requests page; pools are on residential proxies.

Step 6: be a polite scraper

  • Read robots.txt before crawling a host and skip what it disallows — respecting robots.txt shows how in Python.
  • Collect only public pages a logged-out visitor sees. Never work around a login, paywall or CAPTCHA.
  • Keep concurrency per host low, and slow down when the site returns 429 or 503.
  • Store only the fields you need, and leave personal data out unless you have a lawful basis for it.

Code

Worked example

scrape.py — session, retries with backoff, proxy, parsing and pagination
import csv, random, timefrom urllib.parse import urljoinimport requestsfrom bs4 import BeautifulSoupfrom requests.adapters import HTTPAdapterfrom urllib3.util import RetryPROXY = "http://USERNAME_pool-arcfast_country-de:PASSWORD@proxy.arcproxies.io:8000"   # rotating: each new connection can get a different exit IPPROXIES = {"http": PROXY, "https": PROXY}def make_session() -> requests.Session:    s = requests.Session()    retry = Retry(        total=4,        connect=2,        backoff_factor=1,                         # first retry at once, then about 2, 4, 8 s        backoff_jitter=0.5,        status_forcelist=[429, 500, 502, 503, 504],        allowed_methods={"GET", "HEAD"},        respect_retry_after_header=True,          # honours Retry-After on 429 and 503    )    s.mount("http://", HTTPAdapter(max_retries=retry))    s.mount("https://", HTTPAdapter(max_retries=retry))    s.headers["User-Agent"] = "example-price-research/1.0 (+mailto:data@example.com)"    return sdef parse_listing(html: str, base_url: str):    soup = BeautifulSoup(html, "html.parser")    for card in soup.select("article.product"):        name = card.select_one("h2")        price = card.select_one("[data-price]")        link = card.select_one("a[href]")        if not (name and price and link):            continue                               # skip, do not guess        yield {            "name": name.get_text(strip=True),            "price": price["data-price"],            "url": urljoin(base_url, link["href"]),        }    nxt = soup.select_one("a[rel=next]")    yield {"next": urljoin(base_url, nxt["href"]) if nxt else None}def crawl(start_url: str, out_path: str, max_pages: int = 50):    with make_session() as s, open(out_path, "w", newline="") as f:        writer = csv.DictWriter(f, fieldnames=["name", "price", "url"])        writer.writeheader()        url = start_url        for _ in range(max_pages):            r = s.get(url, proxies=PROXIES, timeout=(5, 30))   # per request: env proxies cannot override            r.raise_for_status()                   # e.g. 403 or 404: not in the retry list, stop here            next_url = None            for item in parse_listing(r.text, url):                if "next" in item:                    next_url = item["next"]                else:                    writer.writerow(item)            if not next_url:                break            url = next_url            time.sleep(1 + random.random())        # be polite between pagesif __name__ == "__main__":    crawl("https://example.com/category/shoes", "products.csv")

FAQ

Questions

Something not covered? Write to contact@arcproxies.io.

Is Python a good language for web scraping?

Yes. Python has mature libraries for every part of the job — requests and HTTPX for fetching, Beautiful Soup and lxml for parsing, Scrapy for large crawls and Playwright for pages that need a browser — and its data tooling makes the results easy to analyse.

What is the best Python library for web scraping?

There is no single best one. requests with Beautiful Soup suits focused jobs and learning; Scrapy suits large, scheduled crawls; HTTPX or aiohttp suit high concurrency; Playwright suits pages that only render in a browser.

How do I scrape a page that loads its data with JavaScript?

First open the browser's network panel and look for the JSON request that delivers the data; requesting it directly is faster and lighter. If there is none, render the page with a headless browser such as Playwright and extract from the rendered DOM.

How do I avoid getting blocked when scraping with Python?

Behave like a considerate client: low concurrency per host, pauses between requests, backoff on 429 and 503, an honest User-Agent, and only public pages that robots.txt allows. Proxies help spread a legitimate workload and choose a country; they are not a way around a site's limits.

How do I use a proxy with Python requests?

Pass a dictionary such as {"http": url, "https": url} as proxies= on each request, with the username and password in the URL: http://user:pass@host:port. For SOCKS5, install requests[socks] and use a socks5h:// URL.

Is web scraping with Python legal?

The language does not change the legal position. Collecting public, logged-out data is widely practised, but terms of service, database rights, copyright and data-protection law can all apply. Take advice for your use case; this is not legal advice.

Sources & review

Last reviewed by the ARC Proxies team. Prices are read from the live catalog at build time.

  1. [1]Requests: Advanced Usage: sessions, proxies, timeouts and automatic retries
  2. [2]urllib3: urllib3.util.Retry
  3. [3]Beautiful Soup: Beautiful Soup documentation
  4. [4]IETF: RFC 9309: Robots Exclusion Protocol