SQLite-backed persistent resume and retry for Scrapy spiders.
Scrapy spiders lose all crawl progress when the process stops — whether from a crash, a deploy, or a deliberate shutdown. Restarting means re-crawling every URL from scratch.
scrapy-sqlite persists every crawled URL and its outcome in a SQLite database. When the spider starts again, it picks up exactly where it left off — already-done URLs are skipped, in-flight URLs are retried, and failed URLs with retry budget are re-attempted.
No external services. No configuration. Just a single-file SQLite database.
flowchart TD
S[Spider yields Request] --> DF[SQLiteDupeFilter]
DF -->|new URL| I[INSERT request row<br/>state=running]
DF -->|already done| SKIP[Skip]
DF -->|failed + retries remain| R[Re-allow]
I --> DL[Downloader]
R --> DL
DL --> CB[Spider callback]
CB --> PL[SQLitePipeline]
PL -->|2xx| DONE[UPDATE state=done]
PL -->|error| FAIL[UPDATE state=failed<br/>increment retry_count]
DONE --> RESP[INSERT response row]
FAIL --> RESP
Three components replace Scrapy's defaults:
| Component | Scrapy default | scrapy-sqlite replacement |
|---|---|---|
| Dupefilter | RFPDupeFilter (in-memory) |
SQLiteDupeFilter (persistent) |
| Pipeline | None | SQLitePipeline (stores responses) |
| Spider base | scrapy.Spider |
SQLiteSpider (resumes unfinished requests) |
- Seed URLs are inserted into the
requesttable withstate = 'pending'. - The spider's
start()reads unfinished rows, deserializes them intoRequestobjects, and yields them. - Each request passes through
SQLiteDupeFilter.request_seen()— new fingerprints get anINSERT(state =running), known fingerprints check their existing state. - When the spider yields an item with
sqlite_request_id,SQLitePipelinewrites the response and marks the request asdone(orfailed).
- The spider starts.
start()reads rows wherestate IN ('pending', 'running')or(state = 'failed' AND retry_count < max_retries). - Those requests are deserialized and re-crawled.
- Already-done requests are skipped by the dupefilter.
When a request is serialized for storage, its callback is recorded as a fully-qualified name (module.Class.method). On resume, scrapy-sqlite resolves that name back to the actual callable via importlib:
| Callback type | Example | Resolution |
|---|---|---|
| Instance method | self.parse_item |
Resolved from class, bound to self |
@classmethod |
cls.parse_class |
Resolved already bound to the class |
@staticmethod |
parse_static |
Resolved as plain function |
| Module function | module.parse |
Resolved via importlib |
No callback registry to maintain — callbacks work by default as long as the spider class is importable.
request response
───────── ────────
id (PK) id (PK)
url request_id (FK → request.id)
fingerprint (UNIQUE, run_id) status
pickled_request (BLOB) body (BLOB)
parent_id (FK → request.id) created_at
depth
state -- pending | running | done | failed
retry_count
max_retries
run_id -- isolates multiple crawl runs
created_at
updated_at
The UNIQUE(fingerprint, run_id) constraint makes fingerprint collisions per-run — different runs can crawl the same URL independently.
pip install scrapy-sqliteOr for development:
git clone <repo-url>
cd scrapy-sqlite-ext
pip install -e .from scrapy import Request
from scrapy.http import Response
from scrapy_sqlite.mixin import SQLiteSpider
class MySpider(SQLiteSpider):
name = "my_spider"
custom_settings = {
"DUPEFILTER_CLASS": "scrapy_sqlite.dupefilter.SQLiteDupeFilter",
"SQLITE_DB_PATH": "my_spider.db",
"ITEM_PIPELINES": {
"scrapy_sqlite.pipeline.SQLitePipeline": 300,
},
}
def parse(self, response: Response):
request_id = response.meta.get("sqlite_request_id")
yield MyItem(
scrape_endpoint=response.url,
result=response.body,
status=response.status,
sqlite_request_id=request_id,
)
# Follow links with crawl-chain tracking
for link in response.css("a::attr(href)").getall():
if request_id is not None:
yield self.child_request(
request_id,
url=response.urljoin(link),
callback=self.parse,
)Items processed by SQLitePipeline must have these attributes:
| Attribute | Type | Required |
|---|---|---|
scrape_endpoint |
str |
Yes |
result |
bytes |
Yes |
sqlite_request_id |
int |
Yes — ties the response to its request row |
status |
int |
No — 2xx marks the request done, anything else marks it failed |
Items without both scrape_endpoint and result pass through unchanged.
Before running the spider, insert seed URLs:
import os
import sqlite3
from scrapy import Request
from scrapy.utils.request import RequestFingerprinter
from scrapy_sqlite.serialization import serialize_request
from scrapy_sqlite.backend import init_db
conn = sqlite3.connect("my_spider.db")
init_db(conn)
fp = RequestFingerprinter().fingerprint(Request("https://example.com")).hex()
conn.execute(
"INSERT OR IGNORE INTO request "
"(url, fingerprint, pickled_request, state, max_retries, run_id) "
"VALUES (?, ?, ?, 'pending', 3, ?)",
("https://example.com", fp, serialize_request(Request("https://example.com")), "run-1"),
)
conn.commit()
conn.close()Or use SQLiteSpider.insert_seed() from within the spider:
spider.insert_seed(Request("https://example.com", callback=self.parse), max_retries=3)Set SQLITE_RUN_ID in the environment to isolate crawl runs. Requests from a previous run won't interfere with the current one:
SQLITE_RUN_ID=2026-07-27 scrapy crawl my_spider| Setting | Default | Description |
|---|---|---|
SQLITE_DB_PATH |
(required) | Path to the SQLite database file |
SQLITE_MAX_RETRIES |
3 |
Default max_retries for new requests |
DUPEFILTER_CLASS |
(required) | Set to scrapy_sqlite.dupefilter.SQLiteDupeFilter |
Set on individual requests for fine-grained control:
| Meta key | Purpose |
|---|---|
sqlite_parent_id |
Links this request to its parent (set via child_request()) |
sqlite_run_id |
Override the environment's run ID for this request |
sqlite_max_retries |
Override the dupefilter's default max_retries |
sqlite_request_id |
Set automatically — the row ID after request_seen() |
The sample_project/ directory contains a working spider that scrapes quotes.toscrape.com.
cd sample_project/
# Normal end-to-end run (DB resets each time)
python run.py
# Test callback resolution across resume
python run.py resume-test
# Inspect the database
python run.py statusCrawls all 10 pages of quotes and every author detail page. Each run starts fresh — the database is reset automatically.
Inserts three simulated "interrupted" requests with different callback types, then resumes them:
[instance] http://quotes.toscrape.com → resolved by: quotes
[classmethod] http://quotes.toscrape.com/page/2/ → resolved by: quotes
[staticmethod] http://quotes.toscrape.com/author/... → resolved by: staticmethod
This proves the callback resolution chain works for instance methods, classmethods, and staticmethods — all three survive the serialize → deserialize → bind cycle.
- Crawl chains:
child_request()links child URLs to their parent, tracking depth and lineage in the database. - Custom callbacks:
parse_authorsurvives resume via fully-qualified name resolution. - All binding styles: Instance methods are bound to
self, classmethods are already class-bound, staticmethods are left as plain functions. - Pipeline integration:
ProcessResultitems carrysqlite_request_idso the pipeline can map responses back to request rows.
- Response body storage. Every response body is stored as a BLOB in the
responsetable. For large crawls (millions of pages, large payloads) the database can grow substantially. The body is stored for audit and debugging; if you don't need it, subclassSQLitePipelineand override_writeto skip theINSERT INTO response. - One database per process. The connection is a process-global singleton. Two spiders in the same process with different
SQLITE_DB_PATHsettings will raiseValueError. Each spider should run in its own process with its own database file. - Single-threaded model. All writes run on the reactor thread. A slow
_write(e.g. a very large response body on a spinning disk) will briefly block the event loop. In practice SQLite writes are microsecond-latency; this is the same tradeoff Scrapy's ownRFPDupeFiltermakes.
- SQLite, zero infrastructure. A single file works for any spider — local dev, CI, production.
- WAL mode. Write-Ahead Logging allows concurrent reads (the dupefilter) and writes (the pipeline) without locking.
- Single-threaded access. All database writes run on Scrapy's reactor thread, avoiding the cross-thread commit races that plague
asyncio.to_thread()patterns. - Fully-qualified callback names. Storing
module.Class.methodstrings instead of pickling bound methods avoids the "cannot pickle sqlite3.Connection" problem — spider instances carry unpicklable state. - Backward compatible. Old serialized data without
cb_kwargsdeserializes correctly.
MIT