Reading layout

Storing and Exporting Scraped Data

Where scraped records land decides how much of a crawl survives a crash, and this guide — part of Scaling Python Web Scrapers — covers choosing that destination and writing to it without losing or duplicating rows. The scope is the write path: format selection, batching, deduplication keys, and the failure modes that only appear on the second run.

Storage format decision Small flat exports use CSV or JSON; local querying and dedup use SQLite; concurrent or relational workloads use PostgreSQL; large analytics use Parquet. Volume & use?CSV / JSONsmall, flat,portable exportSQLitelocal querying,dedup, resumablePostgreSQLconcurrent,relationalParquetlarge datasets,analytics
Pick the sink by volume and use — files for small exports, databases and Parquet at scale.

Storage is the stage where scraping mistakes become permanent. A parser bug produces a wrong value once and is fixed on the next run; a missing unique constraint produces four copies of every record and there is no clean way to tell which copy came from which crawl. The decisions worth being deliberate about are small in number: what the natural key is, how often you commit, what happens to a partially written file, and whether the format you chose can still be read at a hundred times the current volume.

When to Use Each Sink

Volume and useSinkWhat it costs you
Under ~100k flat rows, hand-off to a spreadsheetCSVNo types, no nesting, no concurrent writers
Any size, append-as-you-go, nested recordsJSON LinesLarger files, full scan for any query
Under ~10M rows, local, needs dedup and queriesSQLiteOne writer at a time
Concurrent writers, relational joins, long lifePostgreSQLA server to run and back up
Tens of millions of rows, analytical queriesParquetNeeds pyarrow; awkward to append
Raw HTML you may want to re-parseObject store, gzippedNot queryable without a pass

Two heuristics cover most cases. If the data will be read by a person, use CSV or a spreadsheet export; if it will be read by a program, use a database or Parquet. And if the crawl runs more than once against overlapping URLs, you need a keyed store — a file format has no way to express "this record replaces that one".

Keeping the raw response bodies alongside the extracted records is worth considering separately. Gzipped HTML compresses to roughly 15–20% of its original size, so a million pages averaging 120 KB stores in about 24 GB. That is cheap insurance: when a selector turns out to have been wrong, you re-parse from disk instead of re-crawling the site.

Prerequisites

Python 3.10 or newer. sqlite3, csv and json ship with the standard library; the rest are optional and only needed for the sinks you actually use.

pip install "pydantic==2.9.2" "psycopg[binary]==3.2.3" "pyarrow==17.0.0" "pandas==2.2.3"

For PostgreSQL, a local server is enough to follow along:

docker run --rm -e POSTGRES_PASSWORD=scraper -p 5432:5432 postgres:16-alpine

Step-by-Step: Building a Durable Write Path

1. Choose the Physical Layout Before the Library

Row stores, column stores and document stores differ in which access pattern is cheap, and that decision outlives whichever library you use to write them.

Row, column and document layouts for the same three records The same three scraped records are drawn three times. A row store keeps each record's fields adjacent, a column store keeps each field's values adjacent across records, and a document store keeps one self-describing blob per record. Three records, three physical layoutsRow storeCSV, SQLite, Postgres — one record is cheap, one column is a full scantitlepriceurltitlepriceurltitlepriceurlrecord 1record 2record 3Column storeParquet — one column over millions of rows is cheap, one record is nottitletitletitlepricepricepriceurlurlurltitle columnprice columnurl columnDocument storeJSON Lines — append-only and self-describing, but no column pruning{title, price, url}{title, price, url}{title, price, url}
Storage format is a physical layout decision. Whichever axis your bytes are grouped along is the axis your queries will be fast on.

A row store keeps all of one record's fields adjacent, so reading or updating a single record touches one page of disk. A column store keeps all values of one field adjacent, so SELECT avg(price) over ten million rows reads one narrow, well-compressed column and ignores the rest — often a 20× reduction in bytes read. A document store keeps a self-describing blob per record, which tolerates schema drift but forces a full parse to answer anything.

Scraped data is written once and read many times, usually analytically, which is why Parquet wins so decisively at scale. It is also why appending a single record to Parquet is awkward: the format's compression and statistics are computed per row group, so "append" means writing a new file. The standard pattern is to accumulate in JSON Lines during the crawl and convert to Parquet as a separate step — covered in Exporting Scraped Data to CSV and Parquet.

2. Validate at the Boundary

The write is the last place a bad record can be stopped cheaply. After it, the bad value is in every downstream report. A typed model gives you a single definition of what a valid record is, and an error naming the field rather than a silent None.

from datetime import date

from pydantic import BaseModel, Field, HttpUrl, ValidationError, field_validator


class Book(BaseModel):
    url: HttpUrl
    upc: str = Field(min_length=8, max_length=32)
    title: str = Field(min_length=1)
    price_gbp: float = Field(gt=0, lt=10_000)
    in_stock: bool
    scraped_on: date

    @field_validator("price_gbp", mode="before")
    @classmethod
    def parse_price(cls, value: object) -> float:
        if isinstance(value, (int, float)):
            return float(value)
        cleaned = str(value).replace("£", "").replace(",", "").strip()
        return float(cleaned)

    @field_validator("title")
    @classmethod
    def collapse_whitespace(cls, value: str) -> str:
        return " ".join(value.split())


def validate_rows(rows: list[dict[str, object]]) -> tuple[list[Book], list[str]]:
    good: list[Book] = []
    bad: list[str] = []
    for row in rows:
        try:
            good.append(Book(**row))
        except ValidationError as exc:
            bad.append(f"{row.get('url')}: {exc.error_count()} problems")
    return good, bad

Returning the rejects rather than logging and dropping them matters. A rejection rate that jumps from 0.2% to 30% is the clearest early signal that a site changed its markup, and it is only useful if something counts it — see Detecting Silent Scraper Failures. The full modelling vocabulary is covered in Validating Scraped Data with Pydantic, and the normalisation rules that should run before validation in Cleaning and Validating Scraped Data.

3. Write Files Atomically

A process killed mid-write leaves a truncated file, and a truncated CSV or Parquet file is often worse than no file because it parses successfully and returns partial data. Writing to a temporary file in the same directory and renaming it at the end makes the swap atomic on POSIX filesystems.

import csv
import json
import os
import tempfile
from pathlib import Path


def write_csv_atomic(books: list[Book], path: Path) -> None:
    fields = ["url", "upc", "title", "price_gbp", "in_stock", "scraped_on"]
    directory = path.parent
    directory.mkdir(parents=True, exist_ok=True)
    handle = tempfile.NamedTemporaryFile(
        "w", delete=False, dir=directory, newline="", encoding="utf-8", suffix=".tmp"
    )
    try:
        writer = csv.DictWriter(handle, fieldnames=fields, quoting=csv.QUOTE_MINIMAL)
        writer.writeheader()
        for book in books:
            row = book.model_dump(mode="json")
            writer.writerow({key: row[key] for key in fields})
        handle.flush()
        os.fsync(handle.fileno())
    finally:
        handle.close()
    os.replace(handle.name, path)


def append_jsonl(books: list[Book], path: Path) -> None:
    with open(path, "a", encoding="utf-8") as out:
        for book in books:
            out.write(json.dumps(book.model_dump(mode="json"), ensure_ascii=False) + "\n")
        out.flush()
        os.fsync(out.fileno())

Three details are load-bearing. newline="" on the CSV handle prevents the csv module's \r\n line terminator from becoming \r\r\n on Windows. encoding="utf-8" is explicit because Python's default depends on the platform locale, which is how the same script produces mojibake on one machine and clean text on another — see Fixing Common Unicode Errors in Python Scraping. And os.fsync before the rename is what makes the guarantee real; without it the rename can land before the data does.

JSON Lines is the right in-flight format for a running crawl precisely because a truncated final line is trivially recoverable: read line by line, skip anything that fails to parse, and you have lost one record instead of a file.

4. Batch Writes Instead of Committing Per Row

Per-row commits are the single most common reason a scraper's write path becomes the bottleneck. Each commit is a network round trip plus a durable flush of the write-ahead log, and both costs are per commit, not per row.

Insert throughput for three commit strategies A three-bar chart. Committing after every row reaches roughly ninety rows per second, batching five hundred rows per commit reaches about four thousand, and a bulk COPY from a buffer reaches about forty thousand. Loading the same 100,000 rows into PostgreSQLcommit after every row500 rows, one commitCOPY from a buffer90 rows/s4,000 rows/s40,000 rows/s
The cost is the round trip and the fsync, not the row. Batching amortises both, which is why the same table loads two orders of magnitude faster with no schema change.
from collections.abc import Iterator, Sequence

import psycopg

DDL = """
CREATE TABLE IF NOT EXISTS books (
    upc         TEXT PRIMARY KEY,
    url         TEXT NOT NULL,
    title       TEXT NOT NULL,
    price_gbp   NUMERIC(10, 2) NOT NULL,
    in_stock    BOOLEAN NOT NULL,
    scraped_on  DATE NOT NULL,
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT now()
)
"""

UPSERT = """
INSERT INTO books (upc, url, title, price_gbp, in_stock, scraped_on)
VALUES (%s, %s, %s, %s, %s, %s)
ON CONFLICT (upc) DO UPDATE SET
    url        = EXCLUDED.url,
    title      = EXCLUDED.title,
    price_gbp  = EXCLUDED.price_gbp,
    in_stock   = EXCLUDED.in_stock,
    scraped_on = EXCLUDED.scraped_on,
    updated_at = now()
"""


def chunked(items: Sequence[Book], size: int) -> Iterator[Sequence[Book]]:
    for start in range(0, len(items), size):
        yield items[start : start + size]


def save_books(books: list[Book], dsn: str, batch_size: int = 500) -> int:
    written = 0
    with psycopg.connect(dsn) as conn:
        with conn.cursor() as cur:
            cur.execute(DDL)
        conn.commit()
        for batch in chunked(books, batch_size):
            rows = [
                (
                    book.upc,
                    str(book.url),
                    book.title,
                    book.price_gbp,
                    book.in_stock,
                    book.scraped_on,
                )
                for book in batch
            ]
            with conn.cursor() as cur:
                cur.executemany(UPSERT, rows)
            conn.commit()
            written += len(rows)
    return written

ON CONFLICT (upc) DO UPDATE is what makes the crawl idempotent: running it twice produces one row per book with the newer values, not two rows. Choosing that key well is the whole game — a stable product identifier is ideal, the canonical URL is the usual fallback, and the page title is never acceptable because it changes.

A batch size of 500 is a reasonable default. Below about 100 the round-trip overhead still dominates; above a few thousand the transaction holds locks longer and a failure discards more work. Connection setup, indexing and COPY for bulk loads are covered in Saving Scraped Data to PostgreSQL.

5. Use SQLite When There Is Only One Writer

For a local crawl, SQLite gives you the same idempotence with no server. The default journal mode is the main thing to change: WAL allows readers while a write is in progress and is substantially faster for the append-heavy pattern a scraper produces.

import sqlite3
from pathlib import Path

SCHEMA = """
CREATE TABLE IF NOT EXISTS books (
    upc        TEXT PRIMARY KEY,
    url        TEXT NOT NULL,
    title      TEXT NOT NULL,
    price_gbp  REAL NOT NULL,
    in_stock   INTEGER NOT NULL,
    scraped_on TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS books_scraped_on ON books (scraped_on);
"""


def open_db(path: Path) -> sqlite3.Connection:
    conn = sqlite3.connect(path, isolation_level=None)
    conn.execute("PRAGMA journal_mode=WAL")
    conn.execute("PRAGMA synchronous=NORMAL")
    conn.executescript(SCHEMA)
    return conn


def save_batch(conn: sqlite3.Connection, books: list[Book]) -> int:
    rows = [
        (
            book.upc,
            str(book.url),
            book.title,
            book.price_gbp,
            int(book.in_stock),
            book.scraped_on.isoformat(),
        )
        for book in books
    ]
    conn.execute("BEGIN")
    conn.executemany(
        """
        INSERT INTO books (upc, url, title, price_gbp, in_stock, scraped_on)
        VALUES (?, ?, ?, ?, ?, ?)
        ON CONFLICT(upc) DO UPDATE SET
            url=excluded.url, title=excluded.title, price_gbp=excluded.price_gbp,
            in_stock=excluded.in_stock, scraped_on=excluded.scraped_on
        """,
        rows,
    )
    conn.execute("COMMIT")
    return len(rows)

isolation_level=None turns off the driver's implicit transaction handling so the explicit BEGIN/COMMIT pair controls the batch. synchronous=NORMAL under WAL trades a tiny durability window on power loss for a large throughput gain, which is the right trade for data you can re-crawl.

The same insert-or-update logic belongs in a Scrapy pipeline rather than a spider callback when you are working in that framework — see Writing Scrapy Item Pipelines.

6. Convert to Parquet for Analysis

Once the crawl is done, converting JSON Lines to partitioned Parquet gives you a compact archive that analytical tools read directly.

from pathlib import Path

import pyarrow as pa
import pyarrow.json as paj
import pyarrow.parquet as pq

SCHEMA = pa.schema(
    [
        ("url", pa.string()),
        ("upc", pa.string()),
        ("title", pa.string()),
        ("price_gbp", pa.float64()),
        ("in_stock", pa.bool_()),
        ("scraped_on", pa.date32()),
    ]
)


def jsonl_to_parquet(src: Path, dest: Path) -> int:
    table = paj.read_json(src, parse_options=paj.ParseOptions(explicit_schema=SCHEMA))
    dest.parent.mkdir(parents=True, exist_ok=True)
    pq.write_table(table, dest, compression="zstd", row_group_size=100_000)
    return table.num_rows


if __name__ == "__main__":
    count = jsonl_to_parquet(Path("books.jsonl"), Path("warehouse/books.parquet"))
    print(f"converted {count} rows")

Declaring the schema explicitly rather than letting pyarrow infer it prevents the classic failure where a column is int64 in one file and string in the next because one crawl happened to produce a null. zstd compresses roughly 10–20% better than snappy at similar read speed, and a 100,000-row group size keeps column statistics granular enough for predicate pushdown to skip most of the file.

Performance and Scaling Considerations

The commit rate is the throughput ceiling. A single-row commit against a local PostgreSQL is roughly 90 rows per second because it waits for an fsync each time. The same rows in batches of 500 reach several thousand per second, and COPY from an in-memory buffer reaches tens of thousands. If a crawl is fetching 40 pages a second and writing 90 rows a second, the database is the bottleneck and no amount of concurrency will help.

Indexes cost on write and save on read. Every index is updated on every insert. For a bulk load, drop non-essential indexes, load, then rebuild — often a 3–5× improvement. Keep the primary key, since it is what makes the upsert work.

Files grow faster than you expect. A million records of six short fields is roughly 180 MB as CSV, 400 MB as JSON Lines, 240 MB as an uncompressed SQLite table, and 35 MB as zstd Parquet. The JSON Lines penalty is repeated key names on every line, which compresses away almost entirely — gzipping the stream as you write costs a few percent of CPU and removes most of the gap.

Deduplicate on a key, not on the whole record. Comparing full records fails as soon as one whitespace character changes. Use the natural key for exact matching, and reserve similarity matching for the genuinely hard cases described in Deduplicating Records with Fuzzy Matching.

Store when the record was seen, not just what it said. A scraped_on column turns a table into a time series and makes it possible to answer "what changed since the last run" without re-fetching anything. It also pairs with conditional requests, described in Incremental Crawls with ETag and Last-Modified.

Write incrementally, always. Accumulating a full crawl in memory and writing once at the end means an hour-long run has a one-hour window in which any crash costs everything. Flushing every few hundred records bounds the loss to seconds of work.

Common Errors and Fixes

sqlite3.OperationalError: database is locked Another connection holds a write lock, or a long read is blocking a write in the default rollback journal mode. Enable WAL with PRAGMA journal_mode=WAL, set a busy timeout with sqlite3.connect(path, timeout=30), and make sure only one process writes. SQLite is a single-writer database; concurrent writers need PostgreSQL.

UnicodeEncodeError: 'charmap' codec can't encode character '’' The file was opened without an encoding on a system whose locale is not UTF-8. Always pass encoding="utf-8" to open. If a consumer requires a legacy encoding, use errors="replace" deliberately rather than letting it fail on a smart quote.

psycopg.errors.UniqueViolation: duplicate key value violates unique constraint "books_pkey" A plain INSERT hit an existing key on the second run. Add ON CONFLICT (key) DO UPDATE for an upsert or DO NOTHING to keep the first value. Catching the exception and retrying per row works but is orders of magnitude slower.

_csv.Error: field larger than field limit (131072) A CSV cell exceeded the module's default limit, usually because a full HTML blob ended up in a column. Raise it with csv.field_size_limit(10**7), but treat the value as the real bug — large text belongs in a separate file keyed by the record, not in a spreadsheet column.

pyarrow.lib.ArrowInvalid: Failed to parse string: '£24.00' as a scalar of type double A price was written as a string with a currency symbol. Normalise to a number during validation, before it reaches the writer. Parquet columns are strictly typed and will not coerce this for you.

A CSV opens in a spreadsheet with every row in one column The consumer expects a different delimiter for its locale, or the file begins without a byte-order mark and the application guessed the wrong encoding. Write with encoding="utf-8-sig" when the destination is Excel; the BOM is what makes it detect UTF-8.

Row counts grow every run even though the crawl is the same size There is no unique constraint, so each run appends a fresh copy. Add a primary key on the natural key and switch the insert to an upsert. To clean up existing duplicates, delete by keeping the row with the newest scraped_on per key.

Frequently Asked Questions

CSV, JSON Lines, or a database? CSV if a person will open it in a spreadsheet and the data is flat. JSON Lines while a crawl is running, because it appends safely and survives truncation. A database as soon as the crawl repeats over overlapping URLs, because that is the point at which you need a key that says which record replaces which.

What should the deduplication key be? The most stable identifier the site exposes: a product code, an ISBN, a numeric ID in the URL path. Failing that, the canonical URL with tracking parameters stripped. Never a title or a price, and never a hash of the whole record, since either changes when anything on the page is edited.

How often should I commit? Every few hundred records. That bounds the work lost to a crash at a second or two while amortising the fsync cost across enough rows that it stops being the bottleneck. Committing per row is roughly fifty times slower for no practical durability benefit in a re-runnable crawl.

Should I keep the raw HTML? If storage is affordable, yes. Gzipped pages cost around 20 KB each, and having them means a selector bug is fixed by re-parsing locally in minutes instead of re-crawling the site over days. Store them in an object store keyed by a hash of the URL, alongside the fetch timestamp.

Is Parquet worth it for a few hundred thousand rows? Usually not for storage size alone, but it is worth it if the data will be queried repeatedly. The gains come from reading only the columns a query touches and skipping row groups that cannot match, both of which need enough rows to matter. Below roughly a million rows, SQLite is simpler and fast enough.