This is the first of a 4 part mini series to help bring you up to speed with Scrapy, how we use it to scrape the modern web, and how you can extend it to meet your needs. By the end of this series you will be able to write production grade Scrapy code ready for large scale web scraping.
f you have used Scrapy before, you probably remember the satisfying part: write a callback, pull a few fields out of a response, yield a dictionary, and watch a JSON file appear. That workflow still works. The trouble starts when the spider has to run again next week, on a larger catalog, with a missing price, a timeout, or a request that needs different behavior from the rest of the project.
The useful mental shift is to stop treating Scrapy as a convenient loop around requests and selectors. It is a crawler with places for configuration, logging, data processing, and request handling. Using those places is usually less work than rebuilding them in a spider callback.
A working spider is not yet a working Scrapy project
Consider a small catalog spider. It follows product links and extracts a name and price. There is nothing wrong with starting here, and the Scrapy tutorial remains a good way to refresh the basic flow.
1# catalog/spiders/products.py
2import scrapy
3
4
5class ProductsSpider(scrapy.Spider):
6 name = "products"
7 allowed_domains = ["example.com"]
8 start_urls = ["https://example.com/catalog"]
9
10 def parse(self, response):
11 for href in response.css(".product-card a::attr(href)").getall():
12 yield response.follow(href, callback=self.parse_product)
13
14 def parse_product(self, response):
15 yield {
16 "name": response.css("h1::text").get(default="").strip(),
17 "price": response.css(".price::text").get(default="").strip(),
18 "url": response.url,
19 }Run it with scrapy crawl products -O products.jsonl, and you have data. For a short-lived investigation, that may be all you need.
For a project you intend to keep, ask different questions. Which settings apply to every spider? How will you notice that a selector stopped matching? Where should price normalization live? What is the polite request rate for this site? How can you change the output destination without editing extraction code?
Scrapy already has answers. The rest of this article puts them in the places where they remain useful after the initial crawl.
Put crawl behavior in settings, where it can be reviewed and overridden
settings.py is not a graveyard of copied options. It is the project contract: the behavior that should be consistent across spiders unless one has a specific reason to differ. Scrapy defines a clear precedence order, with command-line settings above spider settings, which sit above project settings and defaults. That makes a temporary diagnostic run possible without committing a one-off change to the repository. See the settings documentation for the full order and built-in reference.
Here is a sensible starting point for the catalog project:
1# catalog/settings.py
2BOT_NAME = "catalog"
3
4SPIDER_MODULES = ["catalog.spiders"]
5NEWSPIDER_MODULE = "catalog.spiders"
6
7ROBOTSTXT_OBEY = True
8CONCURRENT_REQUESTS_PER_DOMAIN = 2
9DOWNLOAD_TIMEOUT = 30
10RETRY_TIMES = 2
11
12LOG_LEVEL = "INFO"
13LOG_SHORT_NAMES = True
14
15AUTOTHROTTLE_ENABLED = True
16AUTOTHROTTLE_START_DELAY = 1
17AUTOTHROTTLE_MAX_DELAY = 10
18AUTOTHROTTLE_TARGET_CONCURRENCY = 1.0
19
20FEEDS = {
21 "products.jsonl": {
22 "format": "jsonlines",
23 "encoding": "utf8",
24 "fields": ["name", "price", "url"],
25 "store_empty": False,
26 },
27}The values are examples, not universal defaults. A site may publish crawl guidance that calls for a lower rate, and an internal API might support more concurrency. The important part is that the decision is explicit, visible in review, and separate from parsing code. AutoThrottle adjusts delay using the observed latency while respecting the limits you set. It is a better starting point than guessing a fixed delay, but it is not permission to crawl a site aggressively.
Use the command line when you need a temporary change. This run writes logs to a file without changing the project settings:
1scrapy crawl products \
2 -s LOG_LEVEL=DEBUG \
3 -s LOG_FILE=products-debug.logDo not use per-spider settings just because a setting is nearby. Use them when the difference belongs to that spider. For example, a small, high-value spider might need a separate feed while the project defaults remain unchanged:
1import scrapy
2
3
4class ProductsSpider(scrapy.Spider):
5 name = "products"
6
7 custom_settings = {
8 "FEEDS": {
9 "products.jsonl": {
10 "format": "jsonlines",
11 "encoding": "utf8",
12 }
13 }
14 }The feed exports documentation covers storage backends, batching, field order, and other options that become relevant before you write your own exporter.
Make logs explain the crawl, not narrate it
The default crawl log already contains useful request, response, retry, and item statistics. Your spider should add the missing business context. self.logger is a normal Python logger configured by Scrapy, so it fits into the same output and log-level controls as the rest of the crawl. Scrapy's logging guide explains the project-wide configuration.
Logging every successful product is usually noise. Logging an extraction condition that may change the quality of the dataset is useful:
1def parse_product(self, response):
2 name = response.css("h1::text").get(default="").strip()
3 price = response.css(".price::text").get(default="").strip()
4
5 if not price:
6 self.logger.warning(
7 "Product has no visible price: name=%r url=%s",
8 name,
9 response.url,
10 )
11
12 yield {"name": name, "price": price, "url": response.url}Keep the values as logger arguments rather than formatting the string yourself. Apart from being standard Python logging practice, it keeps the call easy to scan and lets the logging system decide whether the message needs formatting.
When a page is puzzling, use the Scrapy shell before changing the spider. It shortens the loop from "run a crawl, inspect output, edit code" to "inspect the exact response and selector."
1scrapy shell "https://example.com/products/widget-42"1response.css(".price::text").get()
2response.css("script[type='application/ld+json']::text").get()If the shell does not contain the data you see in a browser, that is evidence about the page delivery path, not a reason to immediately add a browser. Part four of this series will cover that choice.
Use pipelines for data rules that apply to every item
It is tempting to clean prices in parse_product(). It is also how normalization gets copied into the next three spiders. A pipeline is the right boundary for item-by-item work that should be consistent across the project: validating required fields, normalizing formats, removing duplicates, or sending items to storage. Scrapy calls each enabled item pipeline in order after a spider yields an item.
This example normalizes a simple dollar price and drops items that cannot be used. In a real project, agree on the currency and numeric representation with the data consumer rather than silently assuming dollars.
1# catalog/pipelines.py
2from decimal import Decimal, InvalidOperation
3
4from scrapy.exceptions import DropItem
5
6
7class ProductPipeline:
8 def process_item(self, item, spider):
9 if not item.get("name"):
10 raise DropItem(f"Missing product name: {item.get('url')}")
11
12 raw_price = item.get("price", "").replace("$", "").replace(",", "").strip()
13 try:
14 item["price"] = Decimal(raw_price)
15 except InvalidOperation:
16 raise DropItem(f"Invalid price {raw_price!r}: {item.get('url')}")
17
18 return itemEnable it in the project settings:
1ITEM_PIPELINES = {
2 "catalog.pipelines.ProductPipeline": 300,
3}The number controls order. That matters when one pipeline depends on work done by another, such as normalizing a URL before a deduplication pipeline sees it. Avoid creating a pipeline for every tiny transformation, though. Page-specific extraction stays in the spider; a rule shared by the project belongs here.
Know what middleware is for before you need it
Pipelines work on items. Middleware works on the crawl itself. Downloader middleware can modify requests before download or responses after download, which makes it a useful home for cross-cutting concerns such as project-wide headers, authentication, or response diagnostics. Spider middleware sits around a spider's input and output.
You do not need to write custom middleware to benefit from it. Scrapy's own retry, redirect, cookies, HTTP cache, and robot exclusion behavior use this component model. The practical lesson for a returning developer is to resist adding request behavior to every callback. First check whether a built-in setting covers it. If it does not, middleware is usually a cleaner home than a growing stack of if statements in the spider.
The same restraint applies to packages and generated code. Tools can get you to a runnable spider quickly, but a runnable spider still needs project-level decisions about output, rate, retries, and data rules. That distinction appears in this recent look at AI-generated Scrapy projects and is why a project structure matters even when an IDE helps write the first callback.
Keep the first project boring on purpose
There is a version of this article that adds a browser, a queue, a custom downloader, a database, and a monitoring system before the first crawl finishes. That is not the version to copy.
Start with a project setting for behavior you want to keep, a log message for a condition you will need to investigate, a pipeline for a data rule shared across spiders, and feed exports for ordinary output. Once those boundaries are in place, adding a specialized component is a design choice rather than a rescue operation.
If you want help building and maintaining the project from inside your editor, Web Scraping Copilot is designed for Scrapy workflows. Zyte's guide to bringing web scraping into the IDE and the Web Scraping Copilot 1.0 introduction both explore that workflow. The code still deserves the same review: inspect the settings, run the shell, and decide where the data rules live.
What comes next: make the spider easier to change
In the next article, we will take the product extraction out of the callback and use scrapy-poet Page Objects to make it reusable and testable. That is where Scrapy's extension ecosystem starts paying for itself, because the framework gives you a clear place to put project-wide behavior before you need a larger architecture.
For now, take one spider you already have and make four changes: move its stable behavior into settings.py, replace its important print() calls with logs, move one shared data rule into a pipeline, and configure a feed export. Those are small changes, but they are the difference between a script that scraped a site once and a Scrapy project you can trust to revisit.






_HFpro5d6k3.png&w=256&q=75)
_E4PyVpfAxa.png&w=256&q=75)


-(1).png&w=1920&q=75)
-(1)_VZGHqxCgXV.png&w=1920&q=75)