By Language 14 min read

Python Web Scraping: The Complete Guide

Master Python web scraping: requests, BeautifulSoup, Scrapy, and headless browsers, plus anti-bot tips. Follow our complete guide to start extracting data.

ST
Scraping.Pro Team
Data collection for business needs
Published: 12 October 2025

Web scraping is the automated collection of data from web pages and its conversion into a structured form: tables, JSON, or a database. Python web scraping has become the de facto standard for this job, and for good reason: the language has clean syntax, a huge ecosystem of libraries, and a mature community that has already solved almost every problem you will hit.

This guide is a hub. We walk through the entire process, from requesting a page to managing URL queues, map out the key libraries and tools, and link off to focused deep-dive articles for the narrow topics. If you are new to scraping with Python, read it top to bottom; if you already scrape, use it as a reference.

What web scraping is and what it's made of

Every scraper, regardless of scale, comes down to four stages:

  1. Fetching the page — an HTTP request to the server and the response it returns (HTML, XML, JSON).
  2. Parsing the content — pulling the data you need out of the markup using selectors (CSS, XPath) or regular expressions.
  3. Normalizing and storing — bringing the data to a single format and writing it to a file or database.
  4. Managing the crawl — the URL queue, deduplication, rate limiting, and retries.

A simple script can fit all of this into ten lines. A production crawler splits each stage into its own layer with queues, proxies, and distributed workers.

Libraries and tools at a glance

To keep things clear, let's group the tools by the role they play.

Fetching pages (HTTP clients)

Library Type When to use
requests synchronous the standard for most jobs, friendly API
urllib synchronous built into the standard library, zero dependencies
httpx sync / async modern replacement for requests with async and HTTP/2
aiohttp asynchronous high concurrency, thousands of requests
pycurl synchronous fine-grained control over the request, maximum speed

Parsing HTML/XML

Library Engine Notes
BeautifulSoup (bs4) html.parser / lxml friendliest API, forgives messy HTML
lxml libxml2 (C) maximum speed, full XPath support
parsel lxml CSS + XPath, the core of Scrapy
selectolax Modest/Lexbor (C) very fast CSS parser for large volumes
pyquery lxml jQuery-style syntax

Dynamic sites (JavaScript)

Tool Purpose
Playwright modern browser automation, fast and stable — the current default
Selenium drives a real browser, the classic choice
Pyppeteer Python port of Puppeteer

Frameworks and platforms

Solution Purpose
Scrapy full crawler framework: queues, pipelines, middleware
Scrapy + Playwright Scrapy with JavaScript rendering
Django + Celery scraping as part of a web app with background tasks

How to choose

  • Simple page, no JavaScript, one-off task → requests + BeautifulSoup.
  • Need speed at volume → httpx/aiohttp + lxml/selectolax.
  • Hundreds of thousands of pages, crawling a whole site → Scrapy.
  • Content rendered by JavaScript → Playwright/Selenium.
  • Scraping embedded in a web service → Django.
  • You need maximum concurrency → an async approach.

Fetching the page

A basic request with requests:

python
import requests

url = "https://example.com"
headers = {
    "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
                  "AppleWebKit/537.36 (KHTML, like Gecko) "
                  "Chrome/126.0.0.0 Safari/537.36",
    "Accept-Language": "en-US,en;q=0.9",
}

response = requests.get(url, headers=headers, timeout=10)
response.raise_for_status()   # raises on 4xx/5xx
html = response.text

Key points:

  • Always set a User-Agent. Many sites block requests carrying the default python-requests string.
  • Always set a timeout, or your script can hang forever.
  • raise_for_status() saves you from checking the status code by hand.

If the page is rendered with JavaScript, requests returns an empty shell. Then you need a browser engine:

python
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch(headless=True)
    page = browser.new_page()
    page.goto("https://example.com")
    page.wait_for_selector(".content")   # wait for the data to appear
    html = page.content()
    browser.close()

If what you actually get back is structured API data rather than HTML, see the dedicated guide on parsing JSON in Python.

Libraries for parsing content

BeautifulSoup — the entry point for beginners

python
from bs4 import BeautifulSoup

soup = BeautifulSoup(html, "lxml")   # the lxml parser is faster than html.parser

title = soup.find("h1").get_text(strip=True)
links = [a["href"] for a in soup.select("a.product-link")]
price = soup.select_one(".price").text

BeautifulSoup forgives broken markup and reads almost like plain English. For pulling tables out of a page, there's a separate walkthrough on scraping tables with BeautifulSoup.

lxml — speed and XPath

python
from lxml import html as lxml_html

tree = lxml_html.fromstring(html)
titles = tree.xpath('//h2[@class="title"]/text()')
prices = tree.xpath('//span[@class="price"]/text()')

lxml is written in C and runs many times faster on large volumes. Full XPath gives you flexibility that CSS selectors can't match. A deep dive lives in Python scraping with lxml.

parsel and selectolax

parsel (the core of Scrapy) unifies CSS and XPath:

python
from parsel import Selector

sel = Selector(text=html)
sel.css("h1::text").get()
sel.xpath("//a/@href").getall()

Reach for selectolax when you need to chew through hundreds of thousands of documents — it's noticeably faster than even lxml on CSS queries.

Regular expressions

re is only appropriate for simple, flat patterns (a phone number, an email, a SKU). Don't try to parse nested HTML with regexes — the structure breaks the moment the markup shifts.

Handling character encodings

A common headache, especially on older sites and non-English content, is garbled text — пÑÐ¸Ð²ÐµÑ instead of readable characters, or caf� instead of café. The cause is an incorrectly detected response encoding.

Why it happens

requests guesses the encoding from the Content-Type header. If the server didn't send it, or sent it wrong, the text is decoded with the wrong codec (often ISO-8859-1 is assumed when the page is really UTF-8, Windows-1252, or a legacy regional charset like Shift_JIS or Windows-1251).

Fix 1: set the encoding manually

python
response = requests.get(url)
response.encoding = "utf-8"      # or the site's real charset
html = response.text

Fix 2: auto-detection

python
response = requests.get(url)
response.encoding = response.apparent_encoding   # detected from the bytes
html = response.text

apparent_encoding uses charset-normalizer (bundled with modern requests), which analyzes the bytes and picks a codec.

Fix 3: work with the raw bytes

The most reliable path is to hand the bytes to the parser and let it read the <meta charset> declaration:

python
from bs4 import BeautifulSoup

response = requests.get(url)
soup = BeautifulSoup(response.content, "lxml")   # .content, not .text

response.content is the raw bytes; lxml and BeautifulSoup will find the encoding declaration inside the HTML themselves.

Fix 4: manual decoding

python
html = response.content.decode("windows-1252", errors="replace")

The errors="replace" option swaps undecodable bytes for instead of crashing your script. For XML documents with non-UTF-8 content, the nuances are covered in parsing XML in Python.

Using multithreading

Scraping is mostly waiting on the network (I/O-bound), so threads give a real speedup despite the GIL: while one thread waits for a response, another runs.

ThreadPoolExecutor — the simplest approach

python
from concurrent.futures import ThreadPoolExecutor
import requests

urls = [f"https://example.com/page/{i}" for i in range(1, 101)]

def fetch(url):
    r = requests.get(url, timeout=10)
    return url, r.status_code

with ThreadPoolExecutor(max_workers=10) as executor:
    for url, status in executor.map(fetch, urls):
        print(url, status)

When to reach for multiprocessing

If your bottleneck is heavy HTML parsing and processing (CPU-bound) rather than the network, threads hit the GIL wall. Then multiprocessing helps — several processes, each with its own interpreter.

Python 3.13 shipped an experimental free-threaded build (PEP 703) that removes the GIL. It's promising for CPU-bound work, but for scraping — which is I/O-bound — async and threads already do the job well.

The best alternative — async

For thousands of simultaneous requests, threads burn too much memory. Async (asyncio + aiohttp) holds tens of thousands of connections in a single thread. That's a big topic on its own — see async web scraping in Python.

A word of caution: high concurrency is not a license to hammer someone else's server. Rate-limit yourself and respect robots.txt.

Using proxies

Under heavy scraping, a site will ban your IP based on request volume. The answer is a proxy pool and rotation.

Simple proxy setup

python
proxies = {
    "http":  "http://user:pass@123.45.67.89:8080",
    "https": "http://user:pass@123.45.67.89:8080",
}
response = requests.get(url, proxies=proxies, timeout=15)

Proxy rotation

python
import random
import requests

PROXIES = [
    "http://user:pass@ip1:port",
    "http://user:pass@ip2:port",
    "http://user:pass@ip3:port",
]

def fetch_with_rotation(url):
    proxy = random.choice(PROXIES)
    return requests.get(url, proxies={"http": proxy, "https": proxy}, timeout=15)

Proxy types

  • Datacenter — cheap and fast, but easy to detect and ban.
  • Residential — real ISP IPs, more expensive but less conspicuous.
  • Mobile — carrier IPs, the most trusted and the priciest.

In production it pays to keep a list of "healthy" proxies: drop the ones that time out or return 403, and re-check them periodically. A deeper walkthrough is in our guide to rotating proxies for scraping.

Scraping through Tor

Tor is a free way to change your exit IP. It's slower than paid proxies and suits modest volumes, but it costs nothing.

Connecting

After installing Tor (the daemon or Tor Browser), it exposes a SOCKS5 proxy on 127.0.0.1:9050:

python
import requests

proxies = {
    "http":  "socks5h://127.0.0.1:9050",
    "https": "socks5h://127.0.0.1:9050",
}
# needs: pip install requests[socks]
r = requests.get("https://httpbin.org/ip", proxies=proxies)
print(r.json())   # you'll see the Tor exit node's IP, not yours

The socks5h scheme (with the h) matters: DNS resolution goes through Tor rather than locally, so your real DNS query doesn't leak.

Getting a new identity (a fresh IP)

To get a new exit node, send the NEWNYM signal over the control port (9051) with the stem library:

python
from stem import Signal
from stem.control import Controller

def renew_tor_ip():
    with Controller.from_port(port=9051) as controller:
        controller.authenticate(password="your_password")
        controller.signal(Signal.NEWNYM)

You need to enable the control port in torrc and set a password hash (tor --hash-password).

Keep in mind: many large sites know the list of Tor exit nodes and either block them or show a CAPTCHA. Tor is good for speed-tolerant, low-volume jobs.

Working with HTTPS/SSL

By default requests verifies SSL certificates via the certifi bundle. Most of the time you don't need to configure anything. Problems show up on sites with self-signed or expired certificates.

Disabling verification (debugging only)

python
import requests
import urllib3

urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
response = requests.get(url, verify=False)   # NOT for production

Turning off verification is unsafe — it opens the door to a man-in-the-middle attack. It's only acceptable locally, for debugging.

The right way — point to your own CA

python
response = requests.get(url, verify="/path/to/custom-ca-bundle.crt")

Updating root certificates

If you hit SSLCertVerificationError on normal sites, upgrade certifi:

bash
pip install --upgrade certifi

Working with cookies

Cookies are needed for sessions, authentication, and getting past "protection" pages that set a token and redirect.

Session stores cookies automatically

python
import requests

session = requests.Session()

# log in — the server returns a session cookie
session.post("https://example.com/login", data={"user": "u", "pass": "p"})

# subsequent requests are already authenticated
profile = session.get("https://example.com/profile")

Session stores and re-sends cookies between requests, and it also reuses TCP connections (faster) and shared headers.

Passing cookies by hand

python
cookies = {"sessionid": "abc123", "csrftoken": "xyz789"}
response = requests.get(url, cookies=cookies)

Reading cookies you received

python
response = requests.get(url)
for name, value in response.cookies.items():
    print(name, value)

Response status and headers

You have to inspect the server's response — otherwise you'll happily parse an error page as if it were data.

python
response = requests.get(url)

print(response.status_code)      # 200, 404, 403, 500 ...
print(response.reason)           # 'OK', 'Not Found'
print(response.headers["Content-Type"])
print(response.headers.get("Server"))
print(response.url)              # final URL after redirects
print(response.elapsed)          # response time

Handling statuses properly

python
import time

if response.status_code == 200:
    parse(response.text)
elif response.status_code == 404:
    log("Page not found")
elif response.status_code == 429:
    # Too Many Requests — we're being throttled
    wait = int(response.headers.get("Retry-After", 60))
    time.sleep(wait)
elif response.status_code in (403, 503):
    rotate_proxy()   # likely a ban — change IP

The Retry-After header tells you how long to wait before retrying. Statuses 403/503 often signal anti-bot defenses: rotating the proxy, changing the User-Agent, and pausing usually help. Persistent walls may need CAPTCHA solving.

Storing URLs and queues (overview)

When a scraper crawls a whole site, you need somewhere to keep the "not yet visited" and "already visited" URLs. This is called the frontier.

The simplest option — in-memory sets

python
from collections import deque

to_visit = deque(["https://example.com"])
visited = set()

while to_visit:
    url = to_visit.popleft()
    if url in visited:
        continue
    visited.add(url)
    # ... download, parse, add new links to to_visit

A set gives instant deduplication; a deque works as a FIFO queue.

When there's a lot of data

  • Redis — a shared queue for several workers that survives restarts. Redis lists and sets are ideal for distributed crawling.
  • A database (PostgreSQL/SQLite) — a URL table with statuses new / in_progress / done / failed, handy for durability and analytics.
  • Task queues (Celery + a broker, RabbitMQ) — when scraping is embedded in an app.
  • A Bloom filter — saves memory at millions of URLs: a probabilistic "have we seen this URL?" check.

In Scrapy, queue management, deduplication, and priorities are built in — one of the main reasons to pick the framework on big projects. Details are in the Scrapy tutorial.

Pros and cons of building it in Python

Pros:

  • Low barrier to entry, readable code, fast prototyping.
  • The richest ecosystem: from requests to Scrapy and Playwright.
  • A huge community — almost any problem is already solved.
  • Easy integration with data analysis (pandas, numpy) and databases.

Cons:

  • The GIL limits CPU-bound processing (worked around with async and multiprocessing).
  • Pure Python is slower than compiled languages on heavy parsing (lxml/selectolax in C rescue you here).
  • Dynamic JS sites require heavy browser engines.
  • Fragility: when a site's markup changes, selectors break — it needs maintenance.

Legal and ethical considerations

Scraping is a powerful tool, and it should be used responsibly:

  • Respect robots.txt and the site's terms of use.
  • Don't create excessive load: add delays, cap concurrency.
  • Don't collect personal data without a lawful basis (keep GDPR and CCPA in mind).
  • Use an honest User-Agent where appropriate, and cache so you don't hit the server more than necessary.

On the legal question specifically, US case law such as hiQ Labs v. LinkedIn has generally treated scraping of publicly accessible data as permissible, while the CFAA and contract terms still matter — and in the EU, GDPR governs any personal data you touch. When in doubt, get advice for your jurisdiction.

FAQ

Which Python library should I start with? requests + BeautifulSoup. It covers the vast majority of static pages and is the gentlest on-ramp to Python web scraping.

How do I scrape a JavaScript-heavy site? Use a headless browser — Playwright is the current default, with Selenium as the classic alternative. Both execute the page's JavaScript so you can read the fully rendered DOM.

How do I avoid getting blocked? Set realistic headers, throttle your request rate, rotate proxies, and respect robots.txt. Aggressive scraping is what gets IPs banned.

Is web scraping legal? Scraping publicly available data is broadly permitted in the US and UK, but it depends on what you collect and how. Avoid personal data without a lawful basis, and read the target site's terms.


Need the data but not the maintenance? scraping.pro runs custom scrapers and delivers clean, structured data on a schedule as a data extraction service — so you get the results without babysitting selectors, proxies, and browser farms. For continuous feeds, our data as a service handles the whole pipeline end to end.