Documentation Blog Free tools [email protected]Log in

How to web scrape using Python: from requests to production

A Python scraper fetching a page, parsing HTML into a tree, and writing structured rowsrequests.get()GET /products?page=1User-Agent: py-scrapervia proxy exit200 OKHTMLBeautifulSoup treediv.cardh3.titlespan.pricerowsclean datasettitle, price, urltitle, price, urltitle, price, urlWhat breaks in productionJS-rendered markuprate limits and blocksselector driftretries and proxy pools

Web scraping in Python means three steps: fetch a page over HTTP with requests, parse the returned HTML with BeautifulSoup or lxml, and write the extracted fields to CSV or JSON. Static pages need nothing more. JavaScript-heavy pages, rate limits and blocks are where the real engineering starts.

The minimum viable Python scraper

Install two packages and you have a working scraper. requests handles the HTTP conversation; beautifulsoup4 turns the response body into a searchable tree. The standard library alternative, urllib.request, works too and needs no install, but you will spend the difference on manual header and encoding handling.

pip install requests beautifulsoup4 lxml

Here is the whole loop against a site built for practice scraping:

import csv, time
import requests
from bs4 import BeautifulSoup

HEADERS = {"User-Agent": "research-bot/1.0 (+mailto:[email protected])"}
BASE = "https://webscraper.io/test-sites/e-commerce/static/computers/laptops"

rows = []
with requests.Session() as s:
    s.headers.update(HEADERS)
    for page in range(1, 4):
        r = s.get(BASE, params={"page": page}, timeout=20)
        r.raise_for_status()
        soup = BeautifulSoup(r.text, "lxml")

        cards = soup.select("div.card, div.thumbnail")
        if not cards:
            break  # layout changed or pagination ended

        for card in cards:
            title = card.select_one("a.title")
            price = card.select_one(".price, h4.price")
            rows.append({
                "title": title.get("title") if title else None,
                "price": price.get_text(strip=True) if price else None,
                "url": requests.compat.urljoin(BASE, title["href"]) if title else None,
            })
        time.sleep(1)  # be polite

with open("laptops.csv", "w", newline="", encoding="utf-8") as f:
    w = csv.DictWriter(f, fieldnames=["title", "price", "url"])
    w.writeheader()
    w.writerows(rows)

print(len(rows), "rows")

Four details in that snippet matter more than the parsing itself. A Session reuses the TCP connection and keeps cookies, which is faster and looks less robotic. timeout stops a hung socket freezing the job forever. raise_for_status() makes a 403 fail loudly instead of silently writing zero rows. And the if not cards: break guard is your canary: when a selector stops matching, you want to know on run one, not after a week of empty CSVs.

Choosing selectors that survive a redesign

Beginners usually reach for find_all("div", class_="sc-1x9bd8a-3") — a hashed CSS-module class that will change the next time the site ships. Prefer anchors that carry meaning:

  1. Structured data first. Look for <script type="application/ld+json"> or a __NEXT_DATA__ blob. Product, price and availability are often already there as JSON, and JSON-LD changes far less often than markup.
  2. Then the hidden API. Open DevTools, Network, filter XHR/Fetch. Many "JavaScript sites" are a thin shell over a clean JSON endpoint you can call with requests directly. This is the single biggest speed win in scraping.
  3. Then semantic HTML. article h2 a, [itemprop="price"], table tbody tr td:nth-of-type(3).
  4. Last, hashed classes — and if you must, assert on shape (row count, price regex) so drift trips an alarm.

Also decide early where the parsing happens. BeautifulSoup with the lxml parser is forgiving and readable. Raw lxml.html plus XPath is faster and better at positional queries like //tr[td[2][contains(., "In stock")]]. Regular expressions on raw HTML work for one-off extraction of an ID or an inline JSON string, and fall apart on nested structure — Real Python's tutorial demonstrates this precisely: a single extra space inside <title > breaks a string-slicing approach that looked fine a moment earlier (Real Python).

Which Python web scraping library for which job

LayerTypical choiceGood forCost you pay
Fetchingrequests / httpxStatic HTML, hidden JSON APIs, anything without JSNothing — fastest path
ParsingBeautifulSoup, lxml + XPathTurning markup into fieldsSelector maintenance
Crawling at scaleScrapyThousands of URLs, concurrency, retries, pipelines built inFramework learning curve
Rendered pagesPlaywright, SeleniumClient-side rendering, logins, infinite scroll~10-50x the CPU and RAM per page
Managed fetchScraping APIBlocks, proxy rotation, JS rendering, Markdown outputPer-page fee instead of engineer-hours

University library guides converge on roughly the same four names — requests, BeautifulSoup, Selenium, Scrapy — plus pandas for the analysis that follows (UT Austin Libraries). That shortlist is still correct. What changed is that step one, fetching, is now the hard part on most commercial sites, not step two.

When the HTML is empty: JavaScript-rendered pages

If print(r.text) shows a shell with an empty <div id="root">, the data arrives after render. Three options, in order of cost:

1. Call the underlying JSON endpoint

Copy the XHR request from DevTools as cURL, strip it back to the minimum headers, and replay it in Python. You get typed fields, pagination cursors and no parsing at all. Check the endpoint is not behind a signed token that rotates per session before committing to it.

2. Render with a headless browser

Playwright is the modern default; Selenium remains fine and is what most tutorials show, driving Chrome or Firefox through a WebDriver. Budget realistically: a browser context is hundreds of megabytes of RAM and a second or two per page, so 100,000 pages a day becomes an infrastructure project, not a script. If you are weighing this trade-off, our primer on browser automation covers where it earns its keep.

3. Offload rendering to an API

A web scraping API renders the page server-side and hands back clean Markdown, HTML or structured JSON. Your Python stays a single requests.post — no driver versions, no Docker image with Chromium in it.

import requests, os

resp = requests.post(
    "https://api.quanticdata.io/v1/scrape",
    headers={"Authorization": f"Bearer {os.environ['QD_API_KEY']}"},
    json={"url": "https://example.com/pricing", "render_js": True},
    timeout=60,
)
payload = resp.json()
print(payload["success"], payload["usage"]["cost_usd"])
print(payload["data"]["markdown"][:400])

Every QuanticData endpoint returns the same envelope — success, data, usage — so your Python error handling is one branch: if success is false, you get a machine-readable error code and the call is billed at $0.00. Rendering is $0.001 per page versus $0.0002 for plain fetch, which is a clean lever: render only the URLs that need it.

Being blocked is a fetching problem, not a parsing problem

Nothing in BeautifulSoup fixes a 403. Once a target starts caring, you are dealing with rate limits, TLS and header fingerprinting, and IP reputation. The practical checklist:

  • Slow down and jitter. Concurrency of 2-5 with randomised delays beats 50 parallel requests that get your IP nulled in ten seconds.
  • Send a coherent header set. A real browser User-Agent with no Accept-Language and no Accept-Encoding is an obvious tell.
  • Retry with backoff on 429/503, respect Retry-After, and never retry a 404.
  • Rotate egress IPs. Datacenter ranges are fine for tolerant targets; residential proxies from $0.80/GB are what you use when the target scores IP reputation. Our guide to rotating proxies in Python has the requests and httpx wiring.
  • Cache aggressively. Write raw HTML to disk on first fetch. Re-parsing a local file while you debug selectors costs nothing and hits nobody's server.
proxies = {
    "http": "http://USER:[email protected]:8000",
    "https": "http://USER:[email protected]:8000",
}
r = s.get(url, proxies=proxies, timeout=30)

Honest cost math: script versus API

Take a realistic job: 50,000 product pages a month, half of them JavaScript-rendered, on a target that blocks naive traffic.

Line itemSelf-hosted Python stackManaged fetch layer
Fetch/render costProxy bandwidth + a headless-browser worker box25,000 x $0.0002 + 25,000 x $0.001 = $30
Failed requestsBilled as bandwidth regardless$0.00 — pay per success
EngineeringSelector fixes, driver upgrades, block triage — call it 4 hours/monthSelector work only if you use CSS extraction
CeilingYour worker countBatch up to 1,000 URLs per job

The point is not that one always wins. Under a few thousand static pages a month, plain requests plus BeautifulSoup is free and takes an afternoon — build it. Once four hours a month of maintenance shows up on the invoice, $30 of per-page fees is cheaper than the engineer, and the failed-request line is where the difference gets uncomfortable: bandwidth-billed proxies charge you for blocks, pay-per-success does not. For whole sites rather than URL lists, the crawl and map API handles BFS traversal at $0.0003 per page and refunds unfetched pages.

Structure a project you will still understand in six months

  1. Separate fetch, parse and store. Three functions, three responsibilities. You should be able to re-run parsing over cached HTML without a single network call.
  2. Version your schema. Every row gets source_url and fetched_at. Without provenance, a dataset is unauditable.
  3. Validate before writing. A pydantic model or a handful of asserts catches "price is the string 'Add to cart'" on day one.
  4. Make runs idempotent. Key on the canonical URL or product ID so a re-run updates rather than duplicates.
  5. Log per-URL outcomes. Status code, attempt count, bytes. Debugging blocks without this is guesswork.
  6. Pin dependencies. A silent lxml or Selenium major bump is the classic "it worked yesterday".

Good starter projects that exercise all of this: a price tracker over 20 SKUs writing to SQLite, a job-board aggregator, or a link-integrity crawler for your own site. All three exist in dozens of GitHub repositories, but write yours from the spec above rather than forking — the value is in the failure handling, which is exactly what copied code omits.

Scraping from an AI agent instead of a cron job

If the consumer of your data is an LLM, the shape changes. Agents want clean Markdown, not HTML soup, and they want to decide at runtime which URLs to fetch. That maps to tools rather than scripts: a web scraping MCP server exposes search, scrape, map, crawl, batch and SEO audit as callable tools inside Claude, Cursor or VS Code, and the web data API for AI returns the same JSON envelope for RAG pipelines. Practically, that means the Python you write shrinks to orchestration — schema definition, deduplication, storage — while fetching, rendering and proxying sit behind one call. For the discovery half of the loop, the SERP API gives you search results as JSON from $0.0005 so the agent finds URLs before it scrapes them.

Legality and etiquette, briefly

Scraping publicly available data is not inherently unlawful in most jurisdictions, but terms of use, copyright, database rights and personal-data rules all apply on top, and the analysis differs by country and by what you collect. Read the target's acceptable use policy and robots.txt, avoid data behind a login unless you have permission, and never collect personal data without a lawful basis. Rate-limit so you are not degrading someone's service. We cover the layers in is web scraping legal in the US. None of this is legal advice — get counsel for anything commercially material.

Sources & further reading

FAQ

Quick answers on how to web scraping using python.

Something else? Ask us →

How do I start web scraping with Python as a beginner?

Install requests and beautifulsoup4, pick a site built for practice such as webscraper.io's test store, and write ten lines: GET the page, parse it with BeautifulSoup, select one element, print it. Then add a second field, then pagination, then CSV output. Do not start with Selenium or Scrapy.

Is BeautifulSoup or Scrapy better for web scraping in Python?

BeautifulSoup is a parser, Scrapy is a full crawling framework — they solve different problems. Use requests plus BeautifulSoup for a handful of pages or a single site. Move to Scrapy when you need concurrency, automatic retries, deduplication and item pipelines across thousands of URLs, and are willing to learn its architecture.

Can Python scrape JavaScript-rendered websites?

Yes, three ways. Find the JSON endpoint the page calls and hit it with requests — fastest and cheapest. Or drive a real browser with Playwright or Selenium, paying roughly a second and hundreds of megabytes of RAM per page. Or offload rendering to a scraping API and keep your Python to a single POST request.

Why does my Python scraper get blocked or return 403?

Almost always fetching, not parsing: too many requests per second, an incoherent header set, or an IP with poor reputation. Add jittered delays, send a full browser-like header set, retry 429 and 503 with backoff, and rotate egress IPs — residential exits when the target scores IP reputation. Cache raw HTML so debugging costs no extra requests.

How much does Python web scraping cost at scale?

The script is free; fetching is not. Self-hosting means proxy bandwidth, headless-browser compute and ongoing maintenance for selector drift and blocks. A managed fetch layer prices it per page — from $0.0002, or $0.001 with JS rendering — with failed calls charged at $0.00. Below a few thousand static pages a month, DIY usually wins.

Do I need proxies for web scraping with Python?

Not for small volumes on tolerant sites. You need them when a target rate-limits by IP, geo-restricts content, or blocks datacenter ranges. Start with datacenter proxies for tolerant targets and move to residential IPs when reputation scoring kicks in. Rotate per request for breadth, use sticky sessions when a flow needs the same IP.

Keep the Python, drop the fetching problem

Point your scraper at one endpoint and get clean Markdown, HTML or structured JSON from $0.0002 per page — $0.001 with JS rendering — with failed calls billed at $0.00. Start with $2 of free usage every month, no card required.

Related reading