HasData
Back to all posts

Scrapy vs. Beautiful Soup: The 2026 Engineering Benchmark

For production use Scrapy. It is a full asynchronous framework built on Twisted. It handles concurrency, throttling, and retries with its default settings. For learning use Beautiful Soup. It is a synchronous parsing library perfect for beginners or simple scripts.

In our tests, Scrapy outperformed standard Beautiful Soup scripts by 39x. Even heavily optimized BS4 scripts lag behind Scrapy’s ecosystem in maintainability.


The architectural difference between Scrapy and Beautiful Soup is asynchronous event-driven I/O against synchronous blocking I/O.

Scrapy is a complete framework built on a non-blocking event loop for high-throughput crawling, while Beautiful Soup is a parsing library that relies on blocking HTTP clients like Python’s requests.

For production data pipelines involving more than 1,000 pages, Scrapy wins on throughput (39x in the benchmark below) because of its non-blocking network engine. Beautiful Soup is the optimal choice for rapid prototyping, single-page extraction, or learning the DOM structure.

Here is the technical decision matrix based on performance, scalability, and maintenance costs:

FeatureBeautiful Soup (bs4)Scrapy
Core ArchitectureParsing Library (Requires requests or httpx)Full Application Framework
I/O ModelSynchronous (Blocking)Asynchronous (Non-blocking / Event Loop)
ConcurrencyManual (requires threading or asyncio)Built-in (CONCURRENT_REQUESTS)
Throughput (10k pages)Low (~1-2 pages/sec sequential)High (~25-50+ pages/sec)
Memory FootprintLow (Minimal overhead)Medium (Requires reactor loop)
Error HandlingManual (try/except blocks)Built-in (Retry middleware, Auto-throttle)
Data ExportManual (Write to file)Built-in (Feed Exports to S3, GCS, JSON/CSV)
JS RenderingNone (Needs Selenium/Playwright)Via middleware (scrapy-playwright) or HasData
Best ForScripts, Prototyping, <100 pages.ETL Pipelines, >10k pages, Data Products.

Throughput and error handling decide most real projects, and the benchmark below puts numbers on the first.

The Event Loop vs. Blocking I/O

The performance gap comes from how the two handle the network layer.

Diagram comparing blocking Synchronous I/O (Beautiful Soup) with non-blocking Asynchronous I/O (Scrapy) showing CPU efficiency and concurrency.

Visualizing Blocking vs. Non-blocking I/O

Beautiful Soup

Beautiful Soup is a parser. It takes a messy HTML string and turns it into a Python object you can traverse, and to get that HTML you usually combine it with the requests library.

The limiting factor is that Python’s requests library is synchronous. When you request Page A, your script halts and sits idle until the server responds, and only after the data arrives does it parse, save, and move to Page B. The CPU spends most of the run waiting for network I/O.

The requests and BS4 stack, timed per request:

import requests
from bs4 import BeautifulSoup
import time
import csv

URL = "https://quotes.toscrape.com"
RUNS = 1000
OUTPUT_FILE = "quotes_bs4_times.csv"

results = []

start_total = time.time() 

for run_id in range(1, RUNS + 1):
    request_start = time.time()
    response = requests.get(URL)  # The script BLOCKS here
    request_end = time.time()
    request_time = request_end - request_start

    parse_start = time.time()
    soup = BeautifulSoup(response.text, "html.parser")
    quotes = soup.select(".quote .text")
    parse_end = time.time()
    parse_time = parse_end - parse_start

    results.append({
        "run_id": run_id,
        "request_time": request_time,
        "parse_time": parse_time,
        "quotes_count": len(quotes)
    })

with open(OUTPUT_FILE, "w", newline="", encoding="utf-8") as f:
    writer = csv.DictWriter(f, fieldnames=["run_id", "request_time", "parse_time", "quotes_count"])
    writer.writeheader()
    writer.writerows(results)

end_total = time.time() 
total_time = end_total - start_total

print(f"Saved results to {OUTPUT_FILE}")
print(f"Total script runtime: {total_time:.2f} seconds")

Every iteration waits for the previous one, so the total is the sum of 1,000 round trips.

Scrapy

Scrapy is a framework built on top of Twisted, an asynchronous networking engine, and it runs on a non-blocking event loop.

When Scrapy requests Page A, it fires the request and immediately moves on to request Page B, C, and D before Page A even responds. It can keep dozens (or hundreds) of requests in flight simultaneously.

The same measurement as a Scrapy spider:

import scrapy
import time
import csv

class QuotesSpider(scrapy.Spider):
    name = "quotes_test"
    allowed_domains = ["quotes.toscrape.com"]
    start_urls = ["https://quotes.toscrape.com"]

    custom_settings = {
        # CSV output
        "FEEDS": {
            "quotes_times.csv": {"format": "csv", "overwrite": True},
        }
    }

    def start_requests(self):
        for i in range(1000):  # 1000 runs
            yield scrapy.Request(
                url=self.start_urls[0],
                callback=self.parse,
                meta={'run_id': i+1, 'request_start': time.time()}
            )

    def parse(self, response):
        request_start = response.meta['request_start']
        request_end = time.time()
        request_time = request_end - request_start

        parse_start = time.time()
        quotes = [q.css('.text::text').get() for q in response.css('.quote')]
        parse_end = time.time()
        parse_time = parse_end - parse_start

        yield {
            'run_id': response.meta['run_id'],
            'request_time': request_time,
            'parse_time': parse_time,
            'quotes_count': len(quotes)
        }

The 1,000 requests go out without waiting for each other, and parse runs as the responses arrive.

Benchmark of Scrapy and BS4 on 1,000 Pages

We ran a controlled test scraping quotes.toscrape.com 1,000 times.

The test ran on this setup:

  • Environment: AWS EC2 t3.medium (2 vCPU, 4GB RAM)
  • Target: 1,000 repeated requests to the same static HTML page.
  • Delay: 0s (pure throughput test).
  • Concurrency: 50 concurrent requests (where applicable).
  • BS4 Setup: Standard requests loop + BeautifulSoup(features='html.parser')
  • Scrapy Optimization: CONCURRENT_REQUESTS=50, DUPEFILTER_CLASS='scrapy.dupefilters.BaseDupeFilter' (to allow redundant URL crawling for the test).

The four architectures finished like this:

ArchitectureStackTime to CompleteSpeed
Async FrameworkScrapy24.41s~41 pages/sec
Sync (Blocking)BS4 + Requests954.29s (~16 min)~1 page/sec
ThreadedBS4 + ThreadPool120.63s (~2 min)~8.3 pages/sec
Custom AsyncBS4 + aiohttp17.79s~56 pages/sec

Using standard requests + bs4 is 39x slower than Scrapy. For a 100,000-page job, that is 41 minutes against 26 hours at the measured rates.

Adding Threading (concurrent.futures) improved speed significantly, but Scrapy was still 5x faster due to the efficiency of the Event Loop over OS-level context switching.

The source code for the benchmark is public. Clone it and run it on your machine.

Wait, isn’t this an unfair comparison?

A senior engineer might look at this and say: “This comparison is unfair! You are comparing a synchronous library (requests) with an asynchronous framework. Of course Scrapy wins. You should compare Scrapy against Beautiful Soup + aiohttp.

You are absolutely right, and row 4 of the table already shows it.

When we wrote a custom script using BeautifulSoup + aiohttp + asyncio, it was actually faster than Scrapy (17.79s vs 24.41s) because it lacked the “middleware overhead” that Scrapy runs by default.

So why don’t we recommend BS4 + aiohttp for everyone?

Maintenance costs matter more than six seconds. To get that performance with BS4, you have to:

  1. Manually manage the Event Loop (asyncio).
  2. Write your own Semaphore logic to limit concurrency (so you don’t DDOS the server).
  3. Write your own error handling and retry logic.
  4. Write your own data export (CSV/JSON) handlers.

Scrapy gives you most of that performance with its defaults (24.41 s against 17.79 s). You trade those 6.6 seconds of raw speed for the retry, throttling, and export code you did not have to write. Scrapy is the batteries-included option, custom async is for when you want to build the batteries yourself.

JavaScript & Blocking

Code that works on quotes.toscrape.com fails on Amazon, LinkedIn, or any modern React/Vue application, and that is where the real work starts. Neither Scrapy nor Beautiful Soup renders JavaScript, so when the data is loaded through AJAX or hydration both tools see an empty page. And Scrapy at full speed against a protected site gets the IP banned in seconds, because it sends a distinct TLS fingerprint that anti-bot systems recognize.

How to Fix This

To make either of these tools production-ready, you need to handle headless browsing and proxy rotation.

Option A, the DIY Way

For Beautiful Soup, you have to wrap your script with Selenium or Playwright to get the HTML, then pass it to BS4.

For Scrapy, the official documentation recommends scrapy-playwright, which lets Scrapy drive a headless Chromium instance to render pages. It is free, software-wise, and renders as well as a desktop Chrome does. Scrapy-Splash, the older option, still installs (scrapy-splash 0.11.1), but Splash itself, the rendering service behind it, has had no commits in about two years at the time of writing and no longer appears in the Scrapy documentation, so start new projects on scrapy-playwright.

The cost is RAM. Fifty concurrent Scrapy requests are cheap, fifty concurrent headless Chrome instances need a much larger machine.

Option B, HasData as Middleware

Managing a rotating proxy pool manually requires writing custom retry logic and complex middleware. In Scrapy, you can offload this complexity entirely to the middleware layer.

Instead of burning your own CPU/RAM, you can integrate HasData to handle proxy rotation and JS rendering. Since the API uses a POST request structure, we need to intercept the spider’s request and recreate it as an API payload. Each request costs credits by configuration (1 for a plain fetch through a datacenter proxy, 10 with JavaScript rendering, 5 and 15 on residential proxies), only successful responses are billed, and the 1,000 free credits at sign-up cover a first test spider.

1. Update settings.py. First, enable the middleware and configure your API key. Ensure your Scrapy concurrency matches your API plan limits to avoid throttling.

# settings.py

HASDATA_API_KEY = "YOUR_API_KEY"

DOWNLOADER_MIDDLEWARES = {
    # Disable default UserAgent middleware if needed
    'scrapy.downloadermiddlewares.useragent.UserAgentMiddleware': None,
    # Enable our custom middleware
    'myproject.middlewares.HasDataMiddleware': 543,
}

# IMPORTANT: Your concurrency must not exceed your HasData plan threads.
# If your plan allows 20 concurrent requests, set this to 20 or lower.
# Otherwise, you will receive 429 Too Many Requests errors.
CONCURRENT_REQUESTS = 20

2. Update your Spider. Add the API domain to your allowed list, otherwise Scrapy’s offsite filter will block the outgoing requests.

# spiders/quotes.py
class QuotesSpider(scrapy.Spider):
    name = "quotes"
    # Add the API domain here to prevent OffsiteMiddleware from blocking requests
    allowed_domains = ["quotes.toscrape.com", "api.hasdata.com"] 
    start_urls = ["https://quotes.toscrape.com"]
    # ...

3. Create middlewares.py. Here is the middleware. It avoids infinite loops by tagging requests and properly reconstructing them for the API.

# middlewares.py
import json
from scrapy.http import Request

class HasDataMiddleware:
    def process_request(self, request, spider):
         # 1. Safety Check: Prevent Infinite Loops
        # If the request is already tagged as an API request, let it pass through.
        if request.meta.get('hasdata_api_request'):
            return None
        
        # 2. Retrieve setup
        api_key = spider.settings.get('HASDATA_API_KEY')
        
        # 3. Construct the API Payload
        payload = {
            "url": request.url,
            "outputFormat": ["html"],  # raw HTML back instead of the JSON envelope, so parse() sees the page
            # "jsRendering": True,  # <--- Uncomment for React/Vue sites
            # "proxyCountry": "US" # <--- Uncomment for geo-restricted data
        }

        # 4. Prepare Meta Data
        new_meta = request.meta.copy()
        new_meta.update({
            'original_url': request.url, # Save original URL to restore it later
            'hasdata_api_request': True, # Flag to prevent infinite loop
            'dont_merge_cookies': True # Let the API handle cookies
        })

        # 5. Create a NEW Request object
        # We do not modify the original request in place. We yield a new one.

        new_request = Request(
            url="https://api.hasdata.com/scrape/web",
            method="POST",
            headers={
                'Content-Type': 'application/json',
                'x-api-key': api_key
            },
            body=json.dumps(payload).encode('utf-8'), 
            meta=new_meta,
            callback=request.callback,  # Preserve original callback
            errback=request.errback,    # Preserve error handling
            dont_filter=True    # Allow duplicate requests (since URL is same API endpoint)
        )
        return new_request
    
    def process_response(self, request, response, spider):
        # 6. Restore the Original URL
        # The spider expects the response to come from "quotes.toscrape.com",
        # not "api.hasdata.com". We trick Scrapy by replacing the URL.
        original_url = request.meta.get('original_url')
        
        if original_url:
            return response.replace(url=original_url)
        
        return response

Now, Scrapy continues to work at high speed (asynchronous), but all complexity regarding Headless Chrome, Proxy Rotation, and TLS Fingerprinting is handled externally. You get clean HTML in your parse function as if the blocking didn’t exist.

Final Recommendations

Here is our heuristic for choosing the right tool:

Choose Beautiful Soup When

  • You are learning. The syntax is forgiving and shows how the DOM works.
  • The project is tiny, one table from Wikipedia for a data science project.
  • You do not want a project folder structure (scrapy startproject) for a 50-line script.

Choose Scrapy When

  • Speed matters. In the benchmark above Scrapy was 39x faster than the standard BS4 approach.
  • You are building a product and need retries, error handling, and structured logging without writing them.
  • The data is deep and the crawl goes from a listing page to a link to a detail page and back. Scrapy’s yield response.follow handles that chain in one line.
  • You want structure. Scrapy separates logic (spiders) from data shape (items) and configuration (settings), which keeps a growing project readable.

Using Beautiful Soup Inside Scrapy

The two combine well. Scrapy’s built-in selectors (XPath/CSS) are fast, but they are strict. If you are scraping old, malformed HTML (unclosed tags, nested tables from the 90s), Scrapy’s lxml-based selectors might fail to build the DOM correctly.

Beautiful Soup is slower but forgiving, so use Scrapy for the network layer and Beautiful Soup for the parsing layer.

import scrapy
from bs4 import BeautifulSoup

class BadHtmlSpider(scrapy.Spider):

    name = "badhtml_bs4"
    allowed_domains = ["badhtml.com"]
    start_urls = ["https://badhtml.com/"]

    def parse(self, response):
        # Using BS4 for the parsing logic
        soup = BeautifulSoup(response.text, "html.parser")
        data = {}

        # BS4's traversal methods
        h1_tag = soup.find("article").find("h1") if soup.find("article") else None
        data["H1"] = h1_tag.text.strip() if h1_tag else ""

        article_links = []
        article = soup.find("article")
        if article:
            for a in article.find_all("a"):
                article_links.append({
                    "Link": a.get("href"),
                    "Text": a.get_text(strip=True)
                })
        data["Article Links"] = article_links

        tips = []
        tip_list = soup.find("ul", class_="tiplist")
        if tip_list:
            for li in tip_list.find_all("li"):
                tips.append(li.get_text(strip=True))
        data["Tips"] = tips

        footer_links = []
        footnotes = soup.find(id="footnotes")
        if footnotes:
            for a in footnotes.find_all("a"):
                footer_links.append({
                    "Footer link": a.get("href"),
                    "Text": a.get_text(strip=True)
                })
        data["Footer Links"] = footer_links

        yield data

You sacrifice the memory efficiency of Scrapy (since BS4 loads the object tree), but you gain the parsing flexibility of Soup, while keeping the asynchronous speed of the Scrapy engine.

Happy Scraping.

HasData runs the proxies and the headless browsers for JS-heavy sites at scale, so the spider above keeps its speed without a browser fleet of your own.

FAQ

Four questions that come up in every Scrapy versus Beautiful Soup thread.

Can Scrapy bypass Cloudflare/recaptcha?

No, not natively. Scrapy is a lightweight HTTP client, not a browser. It cannot execute JavaScript challenges or vary its TLS fingerprint, which is what modern WAFs check. Sites behind them need a browser-based fetcher or a scraping API that renders pages and rotates addresses, which is what the middleware above does.

Is Scrapy faster than Selenium?

Yes, by orders of magnitude. Selenium loads a full web browser (GUI, CSS, Fonts, JS). Scrapy only downloads the raw HTML source code. Comparing them is like comparing a Ferrari (Scrapy) to a School Bus (Selenium). Use Scrapy for data, use Selenium/Playwright only when you absolutely need to render JS.

Which one is better for memory usage?

Scrapy is generally more memory efficient. Beautiful Soup creates a Python object for the entire DOM tree in memory upon loading. For a 10MB HTML file, BS4 can consume 50MB+ of RAM. Scrapy’s selectors allow you to extract data stream-wise without necessarily building the full object tree for the whole page.

Why does my Scrapy spider get banned faster than my BS4 script?

Because Scrapy is too fast. If you don’t configure DOWNLOAD_DELAY or AutoThrottle, Scrapy can hit a server with 50+ requests per second, triggering rate limiters instantly. BS4 scripts are usually slow enough to fly under the radar of basic rate limiters. To use Scrapy safely, you must intentionally slow it down or use a high-quality rotating proxy pool.

Valentina Skakun
Valentina Skakun
Valentina is a software engineer who builds data extraction tools before writing about them. With a strong background in Python, she also leverages her experience in JavaScript, PHP, R, and Ruby to reverse-engineer complex web architectures.If data renders in a browser, she will find a way to script its extraction.
Articles

Might Be Interesting