Skip to content

Guides

Data cleaning and storage for scraped data: schemas, validation, JSONL, and databases

Turn raw scrape output into usable data: define a schema, validate required fields, store raw and normalised records, choose JSONL/Parquet/SQLite/Postgres, and set retention.

Last updated 3 min readBy CrawlerBros Editorial

Tested with Python 3.14.2 on 2 November 2026. Library versions and dates are part of the evidence, not decoration.

Data cleaning turns raw extracted values into records that match a schema: correct types, required fields present, units consistent, duplicates removed. Storage is where those records live so they can be queried, audited, and deleted. A crawl that writes unstructured HTML is not a dataset; it is a pile of pages.

Define the schema before the parser

A schema is a contract between the parser and everything downstream. At minimum, define:

  • the field names and types;
  • which fields are required;
  • the allowed range or enum for constrained fields;
  • the canonical unit (cents vs dollars, UTC vs local time);
  • the stable key used for deduplication;
  • the source URL and the observation time.

A JSON Schema or a Pydantic model is better than a comment, because it can run in CI.

Validate every record

import json

records = [
    {"sku": "A-1", "price": 89.0},
    {"sku": "", "price": -1},
]

for record in records:
    errors = []
    if not record.get("sku"):
        errors.append("sku missing")
    if not isinstance(record.get("price"), (int, float)) or record["price"] < 0:
        errors.append("price invalid")
    print(record, errors or "ok")

Tested on 2 November 2026:

{'sku': 'A-1', 'price': 89.0} ok
{'sku': '', 'price': -1} ['sku missing', 'price invalid']

A validation failure should not silently write a null. Either drop the record with a logged reason or quarantine it for review. Count drops per run; a sudden increase is a layout change.

Store raw and normalised data separately

Keep two layers:

  • Raw: the response body or the extracted JSON exactly as received, plus URL, status, headers, fetched-at.
  • Normalised: the typed record that passed validation, plus the source raw ID.

The raw layer lets you fix a parser bug without re-crawling. The normalised layer is what applications query. Set a retention window for raw (for example, 7–30 days) and keep normalised records longer.

Choose a format

Format Good for Watch out for
JSON Lines Append-only crawl output, nested fields No schema enforcement; duplicates allowed
CSV Spreadsheets, simple flat records Quoting, encoding, no nested fields
Parquet Analytics, columnar queries, compression Not append-friendly without rewriting
SQLite Local single-writer tools, small crawls Concurrent writes are limited
PostgreSQL Multi-user, constraints, queries, durability Operational overhead
Object storage Raw HTML, images, large archives Not queryable without a catalog

A common pattern: JSONL for the crawl output, then load into Parquet or Postgres for analysis. Do not use a spreadsheet as the primary store for a recurring crawl.

JSONL example

import json

record = {
    "source_url": "https://example.com/items?page=1",
    "fetched_at": "2026-11-02T08:00:00Z",
    "sku": "A-1",
    "name": "Mechanical keyboard",
    "price_cents": 8900,
    "currency": "USD",
}

with open("items.jsonl", "a", encoding="utf-8") as handle:
    handle.write(json.dumps(record, ensure_ascii=False) + "\n")

Store money in integer minor units and a currency code. 89.0 is ambiguous; 8900 USD is not. Store timestamps in UTC with an explicit offset.

SQLite for a small crawler

import sqlite3

connection = sqlite3.connect("crawl.db")
connection.execute("""
CREATE TABLE IF NOT EXISTS items (
  sku TEXT PRIMARY KEY,
  name TEXT NOT NULL,
  price_cents INTEGER NOT NULL CHECK (price_cents >= 0),
  currency TEXT NOT NULL,
  source_url TEXT NOT NULL,
  fetched_at TEXT NOT NULL
)
""")
connection.execute(
    "INSERT INTO items (sku, name, price_cents, currency, source_url, fetched_at) "
    "VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(sku) DO UPDATE SET "
    "name=excluded.name, price_cents=excluded.price_cents, currency=excluded.currency, "
    "source_url=excluded.source_url, fetched_at=excluded.fetched_at",
    ("A-1", "Mechanical keyboard", 8900, "USD", "https://example.com/items?page=1", "2026-11-02T08:00:00Z"),
)
connection.commit()
print(connection.execute("SELECT COUNT(*) FROM items").fetchone()[0])
connection.close()

Output:

1

The CHECK constraint is doing real work: a parser bug that writes a negative price fails at the database boundary instead of polluting the dataset.

Retention and deletion

Decide retention before the first crawl:

  • raw HTML: 7–30 days;
  • normalised records: as long as the product needs;
  • personal data: the shortest period that satisfies the purpose, with a documented basis;
  • logs: 30–90 days, with access controls.

Deletion must be possible by source, by record key, and by time. If you cannot delete a record, you cannot honour a data-subject request or a legal hold. Design for deletion before you need it.

Personal data

Scraped data often contains personal data even when the page is public: names, handles, profile URLs, comments. Public does not mean unrestricted. The GDPR guide covers the legal analysis; the engineering rule is simpler: collect the minimum, store it securely, set a retention period, and do not join it into a profile you do not need.

Sources

Sources checked