Scrapy vs BeautifulSoup: Choosing the Right Python Scraping Tool
A hands-on comparison of Scrapy and BeautifulSoup with real code examples, architecture diagrams, performance benchmarks, and a decision framework to pick the right tool for your project.
If you’ve spent any time researching Python web scraping, you’ve seen these two names everywhere: Scrapy and BeautifulSoup. They’re both Python. They’re both used for scraping. But they solve fundamentally different problems, and picking the wrong one for your use case will cost you hours of unnecessary work.
This isn’t a “which is better” article. It’s a “which is right for this specific job” guide, with real code you can run and an architecture breakdown so you understand what’s actually happening under the hood.
The Core Difference
BeautifulSoup is a parsing library. Scrapy is a scraping framework.
That one sentence explains 90% of the decision, but let’s make it concrete.
BeautifulSoup takes HTML as input and gives you a Python object you can search through. That’s it. It doesn’t fetch web pages, handle cookies, manage retries, respect robots.txt, or store data. You pair it with requests (or httpx, or aiohttp) to actually get HTML, and you write everything else yourself.
Scrapy is a complete system. It fetches pages, follows links, manages request queues, handles retries and rate limiting, respects robots.txt, processes data through pipelines, and exports results — all out of the box. You write spiders (the scraping logic) and plug them into the framework.
Architecture Comparison
Here’s how each tool fits into a scraping workflow:
graph TD subgraph "BeautifulSoup Approach" A1[Your Code] --> A2[requests / httpx] A2 --> A3[HTTP Response] A3 --> A4[BeautifulSoup] A4 --> A5[Parsed Data] A5 --> A6[Your Storage Code] end
subgraph "Scrapy Approach" B1[Spider Logic] --> B2[Scrapy Engine] B2 --> B3[Downloader] B2 --> B4[Scheduler / Queue] B3 --> B5[Middleware Stack] B5 --> B1 B1 --> B6[Item Pipeline] B6 --> B7[Export / Storage] end
style A1 fill:#0ea5e9 style A4 fill:#0ea5e9 style B1 fill:#d946ef style B2 fill:#d946efWith BeautifulSoup, you are the architecture. With Scrapy, the framework is the architecture and you plug your logic into it.
Side-by-Side Code Comparison
Let’s scrape the same target with both tools: extracting book titles and prices from a public practice site.
BeautifulSoup + Requests
import requestsfrom bs4 import BeautifulSoupimport csvimport time
BASE_URL = "https://books.toscrape.com"
def scrape_page(url): """Scrape book data from a single page.""" response = requests.get(url, timeout=30) response.raise_for_status() soup = BeautifulSoup(response.text, "html.parser")
books = [] for article in soup.select("article.product_pod"): title = article.select_one("h3 a")["title"] price = article.select_one("p.price_color").text.strip() rating = article.select_one("p.star-rating")["class"][1] books.append({ "title": title, "price": price, "rating": rating, })
# Find next page link next_btn = soup.select_one("li.next a") next_url = f"{BASE_URL}/catalogue/{next_btn['href']}" if next_btn else None
return books, next_url
def main(): url = f"{BASE_URL}/catalogue/page-1.html" all_books = []
while url: print(f"Scraping: {url}") books, url = scrape_page(url) all_books.extend(books) time.sleep(1) # manual rate limiting
# Save to CSV with open("books_bs4.csv", "w", newline="") as f: writer = csv.DictWriter(f, fieldnames=["title", "price", "rating"]) writer.writeheader() writer.writerows(all_books)
print(f"Scraped {len(all_books)} books")
if __name__ == "__main__": main()Lines of code: ~45
External dependencies: requests, beautifulsoup4
What you wrote yourself: HTTP fetching, pagination, rate limiting, CSV export, error handling (minimal here)
Scrapy
import scrapy
class BooksSpider(scrapy.Spider): name = "books" start_urls = ["https://books.toscrape.com/catalogue/page-1.html"]
custom_settings = { "FEEDS": {"books_scrapy.csv": {"format": "csv"}}, "DOWNLOAD_DELAY": 1, "ROBOTSTXT_OBEY": True, }
def parse(self, response): for article in response.css("article.product_pod"): yield { "title": article.css("h3 a::attr(title)").get(), "price": article.css("p.price_color::text").get(), "rating": article.css("p.star-rating::attr(class)").get().split()[-1], }
next_page = response.css("li.next a::attr(href)").get() if next_page: yield response.follow(next_page, self.parse)Lines of code: ~22
External dependencies: scrapy (includes everything)
What you wrote yourself: Just the parsing logic
Run it with:
scrapy runspider books_spider.pyWhat’s Happening Behind the Scenes
The Scrapy version is half the code, but that’s not because it does less. Scrapy’s engine is doing all of this automatically:
- Request scheduling: Queues URLs and deduplicates them
- Rate limiting:
DOWNLOAD_DELAY: 1enforces 1-second delays - robots.txt:
ROBOTSTXT_OBEY: Truechecks before every request - Retry logic: Retries failed requests with exponential backoff
- CSV export: The
FEEDSsetting handles file creation and writing - Logging: Detailed stats on requests, items, and errors
- Memory management: Streams results instead of holding everything in memory
With BeautifulSoup, you’d need to implement each of these yourself.
Feature Comparison
| Feature | BeautifulSoup | Scrapy |
|---|---|---|
| HTML parsing | Built-in (its core purpose) | Uses Parsel (similar API) |
| HTTP requests | No (use requests/httpx) | Built-in async downloader |
| CSS selectors | Yes | Yes |
| XPath selectors | No (use lxml directly) | Yes |
| JavaScript rendering | No | No (use scrapy-playwright) |
| robots.txt handling | No (write it yourself) | Built-in |
| Rate limiting | No (write it yourself) | Built-in |
| Retry logic | No (write it yourself) | Built-in |
| Request queuing | No | Built-in |
| Data pipelines | No | Built-in |
| Export formats | No | CSV, JSON, JSON Lines, XML |
| Middleware system | No | Built-in |
| Concurrent requests | No (use asyncio yourself) | Built-in (16 default) |
| Link following | Manual | response.follow() |
| Duplicate filtering | Manual | Built-in |
| Caching | Manual | Built-in (HTTP cache) |
Performance Comparison
Here’s what you can expect when scraping 1,000 pages from a typical website:
| Metric | BS4 + requests (sequential) | BS4 + asyncio + aiohttp | Scrapy (default settings) |
|---|---|---|---|
| Time to complete | ~17 minutes | ~2 minutes | ~2 minutes |
| Memory usage | ~50 MB | ~80 MB | ~70 MB |
| Code complexity | Low | High | Low |
| Concurrent requests | 1 | Configurable | 16 (default) |
| Lines of code | ~60 | ~120 | ~25 |
The sequential BeautifulSoup approach is the simplest to write but the slowest. You can make it competitive by adding asyncio and aiohttp, but now you’re writing concurrency code — which brings complexity close to “just use Scrapy” territory.
Scrapy’s async engine handles concurrency out of the box. You get parallel downloads without writing a single line of async code.
Memory at Scale
For large scrapes (100K+ pages), Scrapy has an advantage: it processes items through a pipeline and can write them to disk/database incrementally. A naive BeautifulSoup script that accumulates results in a list will eventually eat all your RAM.
# This will run out of memory on large scrapesall_results = []for url in hundred_thousand_urls: data = scrape_with_bs4(url) all_results.append(data) # growing list in memoryScrapy’s yield pattern sends items to the pipeline immediately, keeping memory constant regardless of how many items you scrape.
When BeautifulSoup Is the Right Choice
1. Quick One-Off Extractions
You need to grab data from a single page or a small, known set of pages. Setting up a Scrapy project would be overkill.
# Grab today's top HN story — 5 linesimport requestsfrom bs4 import BeautifulSoup
html = requests.get("https://news.ycombinator.com").textsoup = BeautifulSoup(html, "html.parser")top_story = soup.select_one(".titleline a").textprint(f"Top story: {top_story}")2. Parsing HTML From Non-HTTP Sources
You have HTML from a file, a database, an email, or an API response. You don’t need an HTTP client — you need a parser.
# Parse HTML from a local filewith open("saved_page.html") as f: soup = BeautifulSoup(f, "html.parser") data = soup.select("table.results tr")3. Inside Jupyter Notebooks or Data Pipelines
BeautifulSoup is lightweight and works naturally in interactive environments. You can parse HTML in a notebook cell without spinning up a framework.
4. When You Want Full Control
You’re building a custom scraping system with your own async runtime, your own queue, your own retry logic. You want a parser, not a framework. BeautifulSoup gives you exactly that.
When Scrapy Is the Right Choice
1. Multi-Page Crawls With Link Following
You need to discover URLs by following links across a site. Scrapy’s response.follow() and built-in deduplication make this trivial.
def parse(self, response): # Follow all product links on the page for link in response.css("a.product-link"): yield response.follow(link, self.parse_product)
# Follow pagination yield from response.follow_all(css="a.next-page")2. Recurring/Scheduled Scrapes
You need to scrape the same site daily or weekly. Scrapy projects are organized as reusable code with settings, and you can run them with scrapyd or integrate with schedulers like cron or Airflow.
3. Any Scrape Over ~50 Pages
Once you need rate limiting, retries, robots.txt compliance, and structured output, you’re going to write all of that boilerplate anyway. Scrapy gives it to you for free.
4. When You Need Middleware
Scrapy’s middleware system lets you inject behavior into every request/response without modifying your spider code:
class RotateUserAgentMiddleware: user_agents = [ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) ...", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ...", ]
def process_request(self, request, spider): request.headers["User-Agent"] = random.choice(self.user_agents)Proxy rotation, cookie handling, request signing, response caching — all fit naturally into the middleware pattern.
Can You Use Both Together?
Yes. Scrapy uses Parsel for parsing (similar API to BeautifulSoup), but you can use BeautifulSoup inside a Scrapy spider if you prefer its API:
import scrapyfrom bs4 import BeautifulSoup
class HybridSpider(scrapy.Spider): name = "hybrid" start_urls = ["https://books.toscrape.com"]
def parse(self, response): # Use BeautifulSoup for parsing within Scrapy's framework soup = BeautifulSoup(response.text, "html.parser")
for book in soup.select("article.product_pod"): yield { "title": book.select_one("h3 a")["title"], "price": book.select_one(".price_color").text, }
# Use Scrapy for link following next_page = response.css("li.next a::attr(href)").get() if next_page: yield response.follow(next_page, self.parse)This gives you Scrapy’s infrastructure (concurrency, retries, robots.txt, exports) with BeautifulSoup’s parsing API.
Decision Framework
Use this flowchart when starting a new scraping project:
graph TD A[New scraping task] --> B{Scraping from HTTP?} B -->|No — local file, DB, API| C[BeautifulSoup] B -->|Yes| D{How many pages?} D -->|1-10 pages| C D -->|10-50 pages| E{Need retries, rate limiting?} D -->|50+ pages| F[Scrapy] E -->|No| C E -->|Yes| F
style C fill:#0ea5e9 style F fill:#d946efChoose BeautifulSoup when:
- Parsing HTML that’s already in memory (files, API responses, databases)
- Quick one-off extractions from a handful of pages
- Interactive work in Jupyter notebooks
- You’re building a custom system and just need a parser
Choose Scrapy when:
- Crawling multiple pages with link following
- You need built-in rate limiting, retries, and robots.txt compliance
- Scraping on a schedule (daily, weekly)
- The scrape involves more than ~50 pages
- You want structured exports (CSV, JSON) without writing export code
Getting Started With Each Tool
BeautifulSoup Setup
pip install beautifulsoup4 requestsimport requestsfrom bs4 import BeautifulSoup
response = requests.get("https://example.com")soup = BeautifulSoup(response.text, "html.parser")
# Find elementstitle = soup.select_one("h1").textlinks = [a["href"] for a in soup.select("a[href]")]Scrapy Setup
pip install scrapyscrapy startproject myprojectcd myprojectscrapy genspider example example.comimport scrapy
class ExampleSpider(scrapy.Spider): name = "example" start_urls = ["https://example.com"]
def parse(self, response): yield {"title": response.css("h1::text").get()}Common Pitfalls
BeautifulSoup Pitfalls
1. Forgetting to specify the parser
# This works but gives a warningsoup = BeautifulSoup(html)
# Always specify the parser explicitlysoup = BeautifulSoup(html, "html.parser") # built-in, lenientsoup = BeautifulSoup(html, "lxml") # faster, needs pip install lxml2. Not handling missing elements
# This crashes if the element doesn't existtitle = soup.select_one("h1").text # AttributeError if no h1
# Safer approachh1 = soup.select_one("h1")title = h1.text if h1 else "No title found"Scrapy Pitfalls
1. Blocking the event loop
Scrapy is asynchronous. Don’t use requests or time.sleep() inside spiders — they block the entire event loop.
# BAD — blocks the event loopimport requestsdef parse(self, response): other_page = requests.get("https://...") # blocks everything
# GOOD — use Scrapy's built-in request handlingdef parse(self, response): yield scrapy.Request("https://...", callback=self.parse_other)2. Not checking the logs
Scrapy logs everything: request counts, response codes, dropped items, and detailed error tracebacks. When something isn’t working, the answer is almost always in the logs.
# Run with visible logsscrapy crawl myspider --loglevel=INFOSummary
| BeautifulSoup | Scrapy | |
|---|---|---|
| What it is | HTML parser | Scraping framework |
| Learning curve | 30 minutes | 2-3 hours |
| Best for | Small tasks, parsing, one-offs | Large crawls, production scrapers |
| Handles HTTP | No | Yes |
| Concurrency | Manual | Built-in |
| robots.txt | Manual | Built-in |
| Install size | ~1 MB | ~15 MB |
Neither tool is universally “better.” BeautifulSoup does one thing (parsing) and does it with minimal overhead. Scrapy does everything but carries the weight and opinions of a full framework. Match the tool to the job, and you’ll save yourself hours of unnecessary engineering.
Newsletter coming soon.
Comments
Comments are powered by Giscus. Sign in with GitHub to participate in the discussion.