There’s a point in almost every scraping project where a simple requests + BeautifulSoup script stops being enough — I need to crawl thousands of pages, follow links automatically, respect crawl delays, retry failed requests, and export structured data reliably. That’s exactly the point where I switch to Scrapy. It’s a full application framework for web crawling, not just a parsing library, and understanding its architecture properly made an enormous difference in how effectively I use it. This guide covers Scrapy from the ground up, including the internal request/response cycle that explains why it’s so much faster than sequential scraping approaches.
Why Scrapy Is Different from requests + BeautifulSoup
requests and BeautifulSoup are libraries — I write the entire control flow myself: fetch a page, parse it, decide what to fetch next, loop. Scrapy is a framework — it provides the control flow, and I fill in the specific logic (what to extract, which links to follow) within its structure. The biggest practical difference I noticed immediately is concurrency: Scrapy is built on an asynchronous networking engine (Twisted), meaning it can have dozens of requests in flight simultaneously without me writing any async code myself, whereas a naive requests-based loop processes one page at a time, sequentially, waiting for each response before starting the next.
Installing Scrapy and Creating a Project
pip install scrapy --break-system-packages
scrapy startproject bookscraper
cd bookscraper
This generates a structured project directory:
bookscraper/
scrapy.cfg
bookscraper/
__init__.py
items.py
middlewares.py
pipelines.py
settings.py
spiders/
__init__.py
Writing Your First Spider
A “spider” is Scrapy’s term for the class that defines what to scrape and how. I generate one with a command, or write it directly:
# bookscraper/spiders/books_spider.py
import scrapy
class BooksSpider(scrapy.Spider):
name = "books"
start_urls = ["https://books.toscrape.com/"]
def parse(self, response):
for book in response.css("article.product_pod"):
yield {
"title": book.css("h3 a::attr(title)").get(),
"price": book.css("p.price_color::text").get(),
"availability": book.css("p.instock.availability::text").get(default="").strip(),
}
next_page = response.css("li.next a::attr(href)").get()
if next_page is not None:
yield response.follow(next_page, callback=self.parse)
Running it:
scrapy crawl books -o books.json
This single spider will crawl every paginated page on the site automatically, extract structured data from each book listing, and write the results to books.json — following next_page links until there are none left.
Understanding response.css() and response.xpath()
Scrapy gives me two selector systems, and I mix both depending on what’s easiest for a given element.
# CSS selectors
title = response.css("h1::text").get()
all_links = response.css("a::attr(href)").getall()
# XPath selectors - more powerful for text-based or structural matching
title = response.xpath("//h1/text()").get()
price = response.xpath("//span[@class='price']/text()").get()
.get() returns the first match as a string (or None if nothing matched), while .getall() returns every match as a list. I default to .get() when I expect exactly one result (like a page title) and .getall() when I expect several (like every link on a page).
The Item Pipeline: Structuring and Cleaning Extracted Data
Rather than yielding raw dicts everywhere, I define Item classes for anything beyond a quick throwaway spider — this gives me a defined schema and catches typos in field names early.
# bookscraper/items.py
import scrapy
class BookItem(scrapy.Item):
title = scrapy.Field()
price = scrapy.Field()
availability = scrapy.Field()
# bookscraper/spiders/books_spider.py
import scrapy
from bookscraper.items import BookItem
class BooksSpider(scrapy.Spider):
name = "books"
start_urls = ["https://books.toscrape.com/"]
def parse(self, response):
for book in response.css("article.product_pod"):
item = BookItem()
item["title"] = book.css("h3 a::attr(title)").get()
item["price"] = book.css("p.price_color::text").get()
item["availability"] = book.css("p.instock.availability::text").get(default="").strip()
yield item
Then, in pipelines.py, I define processing steps that every scraped item passes through — cleaning, validating, or storing data.
# bookscraper/pipelines.py
class PriceCleaningPipeline:
def process_item(self, item, spider):
if item.get("price"):
# convert "£10.99" into a clean float
item["price"] = float(item["price"].replace("£", "").strip())
return item
class DuplicatesPipeline:
def __init__(self):
self.seen_titles = set()
def process_item(self, item, spider):
if item["title"] in self.seen_titles:
raise scrapy.exceptions.DropItem(f"Duplicate item found: {item['title']}")
self.seen_titles.add(item["title"])
return item
I enable these in settings.py:
# bookscraper/settings.py
ITEM_PIPELINES = {
"bookscraper.pipelines.PriceCleaningPipeline": 300,
"bookscraper.pipelines.DuplicatesPipeline": 400,
}
The numbers control execution order (lower runs first) — I think of the pipeline as an assembly line, where each stage cleans or validates the item a bit further before it reaches storage.
Internal Working: The Scrapy Engine and Asynchronous Request Handling
This is the part that genuinely changed how I think about scraping performance. Scrapy is built on Twisted, an event-driven asynchronous networking framework. Instead of a single thread blocking on each HTTP request one at a time, Scrapy’s engine maintains a queue of pending requests and dispatches many of them concurrently, processing responses as they arrive, in whatever order they happen to complete — not necessarily the order they were sent.
The core cycle looks roughly like this: the Scheduler holds a queue of pending requests; the Downloader fetches pages, handling many requests in flight simultaneously via Twisted’s non-blocking I/O; as each response comes back, it’s routed to the spider’s parse() method (or whichever callback was specified), which can yield both extracted items and new requests (like the next_page follow-link) in the same generator function; those new requests go back into the Scheduler’s queue, and the cycle continues until there’s nothing left to process.
This is fundamentally why Scrapy dramatically outperforms a naive sequential requests loop for crawling many pages — while waiting for one page’s network response, the engine is simultaneously sending and processing other requests, rather than sitting idle. I control the degree of concurrency directly:
# settings.py
CONCURRENT_REQUESTS = 16 # max simultaneous requests overall
CONCURRENT_REQUESTS_PER_DOMAIN = 8 # max simultaneous requests to a single domain
DOWNLOAD_DELAY = 0.5 # polite delay between requests to the same domain
Respecting robots.txt and Being a Good Citizen
Scrapy checks robots.txt by default, and I keep this enabled unless I have a specific, justified reason not to.
# settings.py
ROBOTSTXT_OBEY = True
I also set a descriptive USER_AGENT and a reasonable DOWNLOAD_DELAY, since hammering a site with maximum concurrency and no delay is exactly the kind of behavior that gets scrapers IP-banned and, more importantly, degrades service for a site’s legitimate users.
USER_AGENT = "bookscraper (+https://mywebsite.example.com/contact)"
DOWNLOAD_DELAY = 1
AUTOTHROTTLE_ENABLED = True # dynamically adjusts delay based on server response times
AUTOTHROTTLE_ENABLED is a setting I turn on for almost every real crawl — it automatically slows down when a server starts responding slowly (a sign it’s under load) and speeds back up when the server is responding quickly, which is far more considerate than a fixed delay that’s either unnecessarily slow on a fast server or too aggressive on a struggling one.
Handling Pagination and Following Links
The response.follow() method is one of Scrapy’s most convenient features — it automatically resolves relative URLs against the current page’s URL, which I’d otherwise have to handle manually with urljoin().
def parse(self, response):
for link in response.css("a.category-link::attr(href)"):
yield response.follow(link, callback=self.parse_category)
def parse_category(self, response):
for product in response.css(".product"):
yield {"name": product.css("h2::text").get()}
Exporting Data in Different Formats
scrapy crawl books -o books.json
scrapy crawl books -o books.csv
scrapy crawl books -o books.xml
scrapy crawl books -o books.jsonl # JSON Lines, one item per line
Scrapy’s built-in feed exporters handle all the formatting, escaping, and structuring automatically based purely on the output file’s extension — I don’t write any export code myself for the common formats.
Handling Errors and Retries
# settings.py
RETRY_ENABLED = True
RETRY_TIMES = 3
RETRY_HTTP_CODES = [500, 502, 503, 504, 408, 429]
Scrapy automatically retries requests that fail with these status codes, using an exponential-ish backoff strategy internally, which has saved me from writing manual retry logic for the vast majority of transient failures.
For errors within my own parsing logic, I define an errback to handle failed requests gracefully rather than letting one bad page silently kill part of the crawl:
import scrapy
class RobustSpider(scrapy.Spider):
name = "robust"
start_urls = ["https://example.com/"]
def start_requests(self):
for url in self.start_urls:
yield scrapy.Request(url, callback=self.parse, errback=self.handle_error)
def parse(self, response):
yield {"title": response.css("title::text").get()}
def handle_error(self, failure):
self.logger.error(f"Request failed: {failure.request.url}")
Using the Scrapy Shell for Rapid Development
Before writing a full spider, I almost always use the interactive shell to test my selectors against a real page first.
scrapy shell "https://books.toscrape.com/"
>>> response.css("h1::text").get()
>>> response.css("article.product_pod")[0].css("h3 a::attr(title)").get()
This iterative testing loop — try a selector, see the result immediately, adjust — saves me enormous amounts of time compared to running the full spider repeatedly just to check whether one CSS selector is correct.
Common Mistakes I’ve Made
- Not enabling
AUTOTHROTTLE_ENABLED, hammering a server too aggressively and getting temporarily or permanently blocked. - Yielding raw dicts everywhere instead of defining
Itemclasses, leading to silent typos in field names across a large spider. - Forgetting
response.follow()handles relative URLs, and manually (and incorrectly) trying to construct absolute URLs myself. - Not using the Scrapy shell and instead running the full spider repeatedly just to debug one selector.
- Ignoring
robots.txtby disablingROBOTSTXT_OBEYwithout a genuinely justified reason.
Real-World Use Cases
- Large-scale product data collection across e-commerce category pages and pagination.
- News and content aggregation, crawling article listings and following links to full articles.
- Price monitoring pipelines that run on a schedule, tracking changes over time.
- Structured data extraction for research, feeding into downstream analysis pipelines.
FAQs
Is Scrapy faster than a requests + BeautifulSoup loop? For crawling many pages, yes, significantly — Scrapy’s asynchronous engine handles many concurrent requests without the sequential blocking a plain requests loop incurs.
Does Scrapy handle JavaScript-rendered pages? Not natively — Scrapy fetches raw HTML like requests does. For JavaScript-heavy sites, it can be combined with scrapy-splash or scrapy-playwright middleware, or you’d use Selenium instead for those specific pages.
How do I avoid getting blocked while scraping with Scrapy? Enable AUTOTHROTTLE_ENABLED, set a reasonable DOWNLOAD_DELAY, use a descriptive USER_AGENT, respect robots.txt, and avoid unnecessarily high concurrency settings.
What’s the difference between response.css() and response.xpath()? Both select elements from the page; CSS selectors are often more concise and familiar, while XPath supports more powerful matching, including text-content-based searches that CSS alone can’t express.
Can I run multiple spiders at once? Yes, using scrapy crawl spider1 & scrapy crawl spider2 from the command line, or programmatically via CrawlerProcess for more control within a single script.
Summary
Scrapy earns its place as a full framework rather than a simple library specifically because large-scale crawling needs concurrency, retry logic, pipeline processing, and export handling that would otherwise mean writing and maintaining a lot of infrastructure code myself. Understanding its asynchronous request/response cycle, built on Twisted, explains both why it’s so much faster than sequential scraping and why respecting concurrency and delay settings matters — the same architecture that makes it fast also makes it capable of overwhelming a target server if used carelessly. Once I understood the engine, spider, and pipeline architecture together, Scrapy stopped feeling like a black box and became the tool I reach for by default whenever a scraping task grows beyond a handful of pages.
References
- Scrapy Official Documentation: https://docs.scrapy.org/en/latest/
- Scrapy Architecture Overview: https://docs.scrapy.org/en/latest/topics/architecture.html
- Twisted Official Documentation: https://twisted.org/