PINGDOM_CHECK

#ExtractSummit2026 The world's largest web scraping conference returns. Austin Oct 7–8 · Dublin Nov 10–11.

Register now
Data Services
Pricing
Login
Try Zyte APIContact Sales
  • Unblocking and Extraction

    Zyte API

    The ultimate API for web scraping. Avoid website bans and access a headless browser or AI Parsing

    Ban Handling

    Headless Browser

    AI Extraction

    SERP

    Enterprise

    DocumentationSupport

    Hosting and Deployment

    Scrapy Cloud

    Run, monitor, and control your Scrapy spiders however you want to.

    Coding Agent Add-Ons

    Agentic Web Data

    Plugins that give coding agents the context to build production Scrapy projects. Starts with Claude Code.

  • Data Services
  • Pricing
  • Browse

    • BlogArticles, podcasts, videos
    • Case studiesCustomer outcomes
    • White papersIn-depth reports
    • DocumentationGuides & API reference
    • EventsConferences, webinars, recordings

    Subscribe

    • NewsletterSwiftly delivered
    • Join our community2,000+ web scraping engineers
  • Product and E-commerce

    From e-commerce and online marketplaces

    Data for AI

    Collect and structure web data to feed AI

    Job Posting

    From job boards and recruitment websites

    Real Estate

    From Listings portals and specialist websites

    News and Article

    From online publishers and news websites

    Search

    Search engine results page data (SERP)

    Social Media

    From social media platforms online

  • Meet Zyte

    Our story, people and values

    Contact us

    Get in touch

    Support

    Knowledge base and raise support tickets

    Terms and Policies

    Accept our terms and policies

    Open Source

    Our open source projects and contributions

    Web Data Compliance

    Guidelines and resources for compliant web data collection

    Join the team building the future of web data
    We're Hiring
    Trust Center
    Security, compliance & certifications
Login
Try Zyte APIContact Sales
All articles
AI71, 71 articles
Data quality15, 15 articles
Developer interest60, 60 articles
Integration2, 2 articles
Open-source50, 50 articles
Proxies35, 35 articles
Scraping practice35, 35 articles
Scraping strategy46, 46 articles
Search results4, 4 articles
Web data74, 74 articles
Web scraping APIs49, 49 articles
Scrapy47, 47 articles
Scrapy Cloud26, 26 articles
Web Scraping Copilot11, 11 articles
Zyte API65, 65 articles
AI & Machine Learning3, 3 articles
Automotive3, 3 articles
E-commerce & retail33, 33 articles
Entertainment & Streaming2, 2 articles
Financial Services8, 8 articles
Government2, 2 articles
Market Research & Intelligence7, 7 articles
Media & publishing11, 11 articles
Real Estate2, 2 articles
Recruitment & HR3, 3 articles
Transportation & Logistics2, 2 articles
Travel & hospitality3, 3 articles
iPaaS2, 2 articles
Large language model29, 29 articles
MCP3, 3 articles
Python110, 110 articles
Scraping at Scale7, 7 articles
Scraping Fundamentals11, 11 articles
Web Scraping Industry Report20, 20 articles

Appearance

Discord Community
BlogModern Scrapy for experienced developers: A new series
ArticleTutorial / How-to

Modern Scrapy for experienced developers: A new series

How to create production ready Scrapy projects to scrape the modern web. In this article we start the process of creating our spider, look at settings, and build a pipeline to help keep our data quality high.

John Rooney · Developer Engagement Manager

July 27, 2026

Modern Scrapy for experienced developers: A new series

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.

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:

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:

Do 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:

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:

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."

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.

Enable it in the project settings:

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.

Try Zyte API

Build your first scraper in minutes

Free trial, no credit card. From a single request to production in an afternoon.

Get started

John Rooney

Developer Engagement Manager

John is the Developer Engagement Manager at Zyte, working closely with the community, creating content and helping developers learn web scraping, Zyte products an much more. He has spoken at Extract Summit's and also creates the workshop's for the events.

  • X (Twitter)
  • LinkedIn
More from this author

In this article

  • A working spider is not yet a working Scrapy project
  • Put crawl behavior in settings, where it can be reviewed and overridden
  • Make logs explain the crawl, not narrate it
  • Use pipelines for data rules that apply to every item
  • Know what middleware is for before you need it
  • Keep the first project boring on purpose
  • What comes next: make the spider easier to change

Follow

Get the latest

Zyte and the data web in your inbox — or wherever you already are.

Subscribe

Or follow elsewhere

The Community · Newsletter

The best of Zyte and the data web, in your inbox.

One curated edition — new articles, product updates, and the stories shaping the data web. No noise.

Services

Zyte Data

Coding tools & hacks straight to your inbox. Bi-weekly dosage of all things code.

Explore Zyte Data

Web Scraping API

Zyte API

Coding tools & hacks straight to your inbox. Bi-weekly dosage of all things code.

Sign Up

Developers

Zyte Developers

Coding tools & hacks straight to your inbox. Bi-weekly dosage of all things code.

Join Us
    • Zyte API
    • Ban Handling
    • AI Extraction
    • SERP
    • Enterprise
    • Scrapy Cloud
    • Agentic Web Data
    • Pricing
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        }
Copy
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}
Copy
1scrapy crawl products \
2  -s LOG_LEVEL=DEBUG \
3  -s LOG_FILE=products-debug.log
Copy
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    }
Copy
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}
Copy
1scrapy shell "https://example.com/products/widget-42"
Copy
1response.css(".price::text").get()
2response.css("script[type='application/ld+json']::text").get()
Copy
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 item
Copy
1ITEM_PIPELINES = {
2    "catalog.pipelines.ProductPipeline": 300,
3}
Copy
    • Product & E-commerce
    • Data for AI
    • Job Posting
    • Real Estate
    • News & Articles
    • Search
    • Social Media
    • Blog
    • Learn
    • Case Studies
    • Webinars
    • White Papers
    • Join our community
    • Documentation
    • Meet Zyte
    • Contact us
    • Jobs
    • Support
    • Terms and Policies
    • Trust Center
    • Do not sell
    • Cookie settings
    • Web Data Compliance
    • Open Source
    • What is Web Scraping
    • Web Scraping in Python: Ultimate Guide
    • Stop getting blocked, start scraping
  • EWDCI logoMost loved workplace certificateZyte rewardISO 27001 iconG2 rewardG2 rewardG2 reward

    © Zyte Group Limited 2026
    XFacebookInstagramYouTubeLinkedInDiscord