Tutorial6 min read

Python Web Crawler: From a Script to Scrapy

Short answer

A Python web crawler is a program that starts from one URL, downloads the page, extracts its links and visits the new ones in turn, usually with Requests and Beautiful Soup for a small crawl and Scrapy for anything large. The core is about 30 lines: a queue of URLs, a set of the ones already seen, a same-site check, robots.txt and a delay between requests. ProxyField's rotating residential proxies spread a crawl's requests over 412M+ IPs in 195+ countries, from $1.95/GB; start with the Easy Trial: 1 GB for $2.95.

residential IPs
412M+
from, traffic never expires
$1.95/GB
Easy Trial
1 GB for $2.95
On this page 10 sections
  1. What a Python web crawler is and what it is used for
  2. Ways to crawl the web in Python
  3. Build a basic web crawler in Python, step by step
  4. Crawling list pages and pagination
  5. A faster, asynchronous crawler with httpx
  6. The Scrapy way: CrawlSpider
  7. Crawlee for Python
  8. Rules every crawler should follow
  9. Proxies for Python crawlers
  10. Python Web Crawler FAQ

What a Python web crawler is and what it is used for

A crawler discovers pages; a scraper extracts data from them. Most real projects do both: the crawler walks a site's category and pagination links, and the scraper reads the product, article or listing on each page it finds. The difference is explained in web crawling vs web scraping.

Python crawlers are used for:

  • Collecting a catalog: every product, job ad or property listing in a section of a site.
  • SEO audits: finding broken links, redirects, missing titles and orphan pages on your own site.
  • Monitoring: re-visiting pages on a schedule to detect price, stock or content changes.
  • Building datasets: gathering public text for research or for search and AI indexes.
  • Archiving: keeping copies of your own or public pages over time.

Ways to crawl the web in Python

ApproachLibrariesConcurrencyUse it for
Simple scriptRequests + Beautiful SoupOne request at a timeLearning; small sites; one-off jobs
Async scripthttpx or aiohttp + asyncioMany requests, with a limit you setMedium crawls you want to control fully
FrameworkScrapy (CrawlSpider)Built in, with per-site limits and auto-throttlingLarge, recurring crawls with retries and pipelines
FrameworkCrawlee for PythonBuilt in, with browser crawlers on the same interfaceCrawls that mix static and JavaScript pages
Browser crawlerPlaywright, or Scrapy with scrapy-playwrightA few pages per browserSites that need JavaScript for links or content

Build a basic web crawler in Python, step by step

This crawler visits up to 100 pages of one site, breadth-first, obeys robots.txt, waits a second between requests and saves each page's URL and title.

Python
# pip install requests beautifulsoup4
import json
import time
from collections import deque
from urllib.parse import urljoin, urldefrag, urlparse
from urllib.robotparser import RobotFileParser

import requests
from bs4 import BeautifulSoup

START = "https://books.toscrape.com/"
MAX_PAGES = 100
DELAY = 1.0                                   # seconds between requests
UA = "Mozilla/5.0 (compatible; demo-crawler/1.0)"

host = urlparse(START).netloc
robots = RobotFileParser(urljoin(START, "/robots.txt"))
robots.read()
session = requests.Session()
session.headers["User-Agent"] = UA

queue, seen, pages = deque([START]), {START}, []
while queue and len(pages) < MAX_PAGES:
    url = queue.popleft()
    if not robots.can_fetch(UA, url):
        continue
    try:
        resp = session.get(url, timeout=20)
    except requests.RequestException as exc:
        print("error", url, exc)
        continue
    if resp.status_code != 200 or "text/html" not in resp.headers.get("Content-Type", ""):
        continue

    soup = BeautifulSoup(resp.text, "html.parser")
    pages.append({"url": url, "title": soup.title.get_text(strip=True) if soup.title else ""})

    for a in soup.select("a[href]"):
        link = urldefrag(urljoin(url, a["href"]))[0]     # absolute, without #fragment
        if urlparse(link).netloc == host and link not in seen:
            seen.add(link)
            queue.append(link)
    time.sleep(DELAY)

json.dump(pages, open("pages.json", "w"), indent=2)
print(len(pages), "pages crawled")

How each part works:

  1. The frontier

    Deque holds URLs to visit; popleft() makes the crawl breadth-first, so pages close to the start come first.

  2. Deduplication

    Seen stops the crawler from visiting a page twice; urldefrag drops #fragments, which point to the same page.

  3. Scope

    Only links on the same host are queued, so the crawler does not wander off across the web.

  4. Politeness

    RobotFileParser checks robots.txt before each request (Python docs: urllib.robotparser), and the delay keeps the load low.

  5. Robustness

    A timeout on every request, errors logged and skipped, and only HTML pages parsed.

To make it a scraper as well, read the fields you need from soup on the pages that hold them, for example only on URLs that contain /catalogue/ and have an h1.

Crawling list pages and pagination

Many crawls do not need every link on a site: they walk a list (a category, search results, a sitemap) and open each item. Restrict the crawler to two kinds of links, the "next page" link and the item links, and you visit a fraction of the pages:

Python
next_link = soup.select_one("li.next a")
if next_link:
    queue.append(urljoin(url, next_link["href"]))          # the next list page
for a in soup.select("article.product_pod h3 a"):
    queue.append(urljoin(url, a["href"]))                   # each item on this page

Check /sitemap.xml (often listed in robots.txt) first: a sitemap gives you every URL the site wants indexed, with lastmod dates, without crawling the navigation at all.

A faster, asynchronous crawler with httpx

When a crawl needs hundreds of pages, fetch several at once with a cap per site. httpx and asyncio keep the code close to the simple version:

Python
# pip install httpx beautifulsoup4
import asyncio
from urllib.parse import urljoin, urldefrag, urlparse
import httpx
from bs4 import BeautifulSoup

START, MAX_PAGES, CONCURRENCY = "https://books.toscrape.com/", 300, 5
host = urlparse(START).netloc

async def crawl():
    seen, queue, done = {START}, asyncio.Queue(), []
    await queue.put(START)
    async with httpx.AsyncClient(timeout=20, follow_redirects=True) as client:
        async def worker():
            while True:
                url = await queue.get()
                try:
                    if len(done) >= MAX_PAGES:
                        continue                         # drain the queue once the limit is hit
                    r = await client.get(url)
                    if "text/html" in r.headers.get("content-type", ""):
                        soup = BeautifulSoup(r.text, "html.parser")
                        done.append(url)
                        for a in soup.select("a[href]"):
                            link = urldefrag(urljoin(url, a["href"]))[0]
                            if urlparse(link).netloc == host and link not in seen:
                                seen.add(link)
                                queue.put_nowait(link)
                    await asyncio.sleep(0.5)            # per-worker pause
                except httpx.HTTPError:
                    pass
                finally:
                    queue.task_done()
        workers = [asyncio.create_task(worker()) for _ in range(CONCURRENCY)]
        await queue.join()
        for w in workers:
            w.cancel()
    print(len(done), "pages")

asyncio.run(crawl())

Five workers with a half-second pause each make about ten requests per second at most; lower CONCURRENCY for small sites.

The Scrapy way: CrawlSpider

Scrapy does all of the above for you (the queue, deduplication, concurrency, retries, robots.txt, throttling and export) and adds pipelines for cleaning and storing data. A CrawlSpider declares which links to follow and which pages to parse:

Python
# pip install scrapy  ->  scrapy runspider books_crawler.py -O books.json
from scrapy.linkextractors import LinkExtractor
from scrapy.spiders import CrawlSpider, Rule

class BooksCrawler(CrawlSpider):
    name = "books"
    allowed_domains = ["books.toscrape.com"]
    start_urls = ["https://books.toscrape.com/"]
    custom_settings = {"ROBOTSTXT_OBEY": True, "DOWNLOAD_DELAY": 1, "CLOSESPIDER_PAGECOUNT": 500}
    rules = (
        Rule(LinkExtractor(allow=r"catalogue/page-\d+\.html")),                        # follow list pages
        Rule(LinkExtractor(restrict_css="article.product_pod h3"), callback="parse_book"),  # open each book
    )

    def parse_book(self, response):
        yield {
            "title": response.css("h1::text").get(),
            "price": response.css("p.price_color::text").get(),
            "url": response.url,
        }

The full walkthrough (project layout, items, errors, scheduling) is in the Scrapy tutorial.

Crawlee for Python

Crawlee is an open-source crawling framework for Python and Node.js (Apache-2.0). Its Python version has HTTP crawlers (with Beautiful Soup or Parsel) and a Playwright crawler with the same interface, plus a persistent request queue and proxy rotation:

Python
# pip install 'crawlee[beautifulsoup]'
import asyncio
from crawlee.crawlers import BeautifulSoupCrawler, BeautifulSoupCrawlingContext

async def main():
    crawler = BeautifulSoupCrawler(max_requests_per_crawl=50)

    @crawler.router.default_handler
    async def handle(context: BeautifulSoupCrawlingContext) -> None:
        await context.push_data({"url": context.request.url, "title": context.soup.title.string})
        await context.enqueue_links()                 # same-site links by default

    await crawler.run(["https://books.toscrape.com/"])

asyncio.run(main())

Rules every crawler should follow

  • Stay in scope: one host or path, a page limit and a depth limit, so a calendar or a filter combination cannot trap the crawler in endless URLs.
  • Normalize URLs: drop fragments, sort or strip tracking parameters, and treat http/https and trailing slashes consistently.
  • Be polite: obey robots.txt, start at about one request per second per site and slow down if responses slow down, crawl off-peak, stop on repeated 429 or 503.
  • Store as you go: write results and the visited set to disk, so a crash resumes instead of restarting.
  • Collect only public pages and check the site's terms; see is web scraping legal and web scraping best practices.

Proxies for Python crawlers

A crawl sends many requests to one site, which is exactly what per-IP rate limits catch. A rotating residential proxy gives each request a new IP; set it once on the session:

Python
proxy = "http://<login>-country-us-city-new_york:<password>@gate.proxyfield.io:7000"
session.proxies = {"http": proxy, "https": proxy}

<login>-country-us-city-new_york asks for an IP in New York, United States. Region, session and session time go in the same username: -region-<region>, -session-<id> and -ttl-<seconds> (30 to 129,600 seconds, 20 minutes by default), as in <login>-country-us-region-new_york-city-new_york-session-he9f2qga-ttl-600; every part after the login is optional. Async clients, SOCKS5 and sticky sessions are in the Python proxy setup guide, and Scrapy's settings in the Scrapy proxy setup guide. ProxyField residential proxies cost $2.95/GB for 1 GB down to $1.95/GB from 1,000 GB, with no subscription; the traffic never expires, and all purchases are final (residential and mobile proxy pricing).

Python Web Crawler FAQ

What is a web crawler in Python?

ProxyField's rotating residential proxies (412M+ residential IPs, from $1.95/GB) spread a crawl over many addresses, and a web crawler in Python is the program that runs it: it starts from one URL, downloads pages, extracts their links and visits the new ones, keeping a queue and a set of seen URLs. Small crawlers use Requests and Beautiful Soup; large ones use Scrapy or Crawlee.

What is a Python web crawler used for?

Mostly for collecting every item in a catalog or listing section and for auditing your own site for broken links and missing titles. Crawlers also monitor pages for changes, build research datasets and archive pages.

How do I build a web crawler in Python?

Keep the frontier in a collections.deque and the visited URLs in a set, download each page with a Requests session, extract links with Beautiful Soup, make them absolute with urljoin, and queue the same-site links you have not seen. Add urllib.robotparser checks, a time.sleep() delay, a page limit and try/except around each request.

What are the different ways to crawl web data in Python?

A simple synchronous script (Requests and Beautiful Soup), an asynchronous one (httpx or aiohttp), a framework (Scrapy's CrawlSpider or Crawlee), or a browser-based crawler (Playwright) for sites that need JavaScript. Reading the site's sitemap is often the quickest way to list its URLs.

How do I crawl data from a website in Python?

Crawl to find the pages and scrape each one: follow the category and pagination links, and on each item page read the fields you need with CSS selectors, then save them to JSON, CSV or a database. Scrapy combines both in one spider.

What is Crawlee for Python?

An open-source crawling framework (Apache-2.0) with HTTP crawlers based on Beautiful Soup or Parsel and a Playwright crawler on the same interface. It manages the request queue, retries, storage and proxy rotation for you.

Which Python library is best for building a web crawler?

For large or recurring crawls, Scrapy, because it ships with concurrency, retries, throttling, robots.txt support and export. For learning or a small site, Requests with Beautiful Soup; for crawls that need JavaScript on some pages, Crawlee or Scrapy with scrapy-playwright.

How do I crawl a list of pages in Python?

Follow only two kinds of links: the "next page" link of the list and the links to each item on it. Or start from the sitemap, which lists every URL the site wants indexed, and skip the navigation entirely.

How do I make a Python crawler respect robots.txt?

Load the file with urllib.robotparser.RobotFileParser, call read() once, and check can_fetch(user_agent, url) before each request; in Scrapy, set ROBOTSTXT_OBEY = True. Whether robots.txt binds you legally is a separate question, covered in our guide to whether web scraping is legal.

Is Python good for web crawling?

Yes, and it is the most common language for it: the standard library parses URLs and robots.txt, Requests and httpx fetch pages, Beautiful Soup parses them, and Scrapy and Crawlee provide full crawling frameworks. Python's limit is CPU-heavy parsing at very large scale, which frameworks handle by running many requests concurrently.

Easy Trial: 1 GB for $2.95.

Try for the price of 1 GB, no subscription, traffic never expires.