---
title: "Gescrapte Daten als CSV, JSON und in SQLite speichern"
description: "Gescrapte Zeilen als CSV für Tabellen, als JSON Lines für fortlaufende Logs und in SQLite für Läufe ohne Duplikate speichern. Mit getestetem Python-Code."
url: https://proxynet.io/de/blog/save-scraped-data-csv-json-sqlite
date: 2026-09-28
author: "Acar Diveroli"
category: "Anleitungen, Web Scraping"
lang: de
---

# Gescrapte Daten als CSV, JSON und in SQLite speichern

Ihr Scraper durchläuft zehn Seiten und gibt hundert saubere Datensätze aus. Am nächsten Morgen starten Sie ihn erneut, und die CSV-Datei hat zweihundert Zeilen, die Hälfte davon Duplikate. Eine Kollegin öffnet die Datei in Excel, und jedes typografische Anführungszeichen erscheint als `â€œ`. Eine Woche später stürzt der Job mitten im Schreiben ab und hinterlässt eine JSON-Datei, die kein Parser mehr öffnet. Keines dieser Probleme hat mit dem Scraping selbst zu tun; entschieden wird all das in den wenigen Speicherzeilen am Ende des Skripts.

Dieser Leitfaden behandelt die drei Formate, in die die meisten Python-Scraper schreiben: CSV mit dem Modul `csv` und pandas, JSON und JSON Lines sowie SQLite mit einem Upsert, der Duplikate über mehrere Läufe hinweg entfernt. Sie sehen, wie Sie sicher anhängen, welche Kodierung Excel erwartet, wie groß jedes Format wird und wann der Wechsel von SQLite zu PostgreSQL sinnvoll ist. Alle Beispiele liefen am 28. September 2026 gegen [quotes.toscrape.com](https://quotes.toscrape.com/), eine Übungsseite, die eigens für Scraping gebaut wurde, mit Python 3.13, Requests 2.34, beautifulsoup4 4.15, pandas 3.0 und SQLite 3.50. Das Parsen selbst behandelt das [BeautifulSoup-Tutorial](/de/blog/beautifulsoup-tutorial); hier setzen wir ein, sobald die Zeilen vorliegen.

> **Hinweis: Kurzantwort**
>
> Schreiben Sie CSV, wenn Menschen die Daten in einer Tabellenkalkulation öffnen: Verwenden Sie `csv.DictWriter`, öffnen Sie die Datei mit `newline=""` und nutzen Sie `encoding="utf-8-sig"`, damit Excel die Zeichen korrekt liest. Schreiben Sie JSON Lines (ein JSON-Objekt pro Zeile), wenn Datensätze verschachtelt sind oder Sie bei jedem Lauf anhängen. Schreiben Sie in SQLite, wenn der Scraper regelmäßig läuft: Geben Sie jedem Datensatz einen stabilen Schlüssel, deklarieren Sie ihn als Primärschlüssel und fügen Sie mit `INSERT ... ON CONFLICT DO UPDATE` ein, sodass ein zweiter Lauf Zeilen aktualisiert, statt sie zu verdoppeln. Wechseln Sie zu PostgreSQL, wenn mehrere Rechner gleichzeitig schreiben oder die Datenbank auf einem Server liegen muss.

## Welche Möglichkeiten gibt es, gescrapte Daten zu speichern?

Es gibt zwei Familien. **Flache Dateien** (CSV, JSON, JSON Lines, XLSX) sind einzelne Dateien, die von oben nach unten geschrieben werden. Sie brauchen keinen Server und öffnen sich in vertrauten Werkzeugen, wissen aber nicht, was ein Duplikat ist. **Datenbanken** (SQLite, PostgreSQL, MySQL) speichern Zeilen in Tabellen mit Schlüsseln und Constraints: Sie führen Duplikate für Sie zusammen, beantworten Fragen per SQL und überstehen einen Absturz mitten im Schreiben.

SQLite liegt dazwischen. Es ist eine Datenbank, aber die gesamte Datenbank ist eine gewöhnliche Datei auf der Festplatte, und Python liefert das Modul `sqlite3` in seiner Standardbibliothek mit. Für einen Scraper, der auf einem Rechner läuft, ist diese Kombination schwer zu schlagen.

## Wie speichert ein Scraper seine Zeilen?

Unabhängig vom Format durchläuft ein sauber gebauter Scraper dieselben fünf Schritte:

1. **Jeden Datensatz in ein Dict normalisieren**, jedes Mal mit denselben Schlüsseln: `quote_id`, `text`, `author`, `tags` und ein Zeitstempel.
2. **Jedem Datensatz einen stabilen Schlüssel geben.** Hat die Website eine eigene ID, nutzen Sie diese (eine Produkt-SKU, eine Artikel-ID in der URL). quotes.toscrape.com hat keine, daher bilden wir aus Autor und Zitattext einen Hash mit 16 Zeichen.
3. **Den Schreibmodus wählen.** Die Datei überschreiben (ein frischer Snapshot), an sie anhängen (ein wachsendes Log) oder per Upsert in eine Tabelle schreiben (eine Zeile pro Schlüssel, direkt aktualisiert).
4. **In einem Schritt schreiben.** Entweder landet der ganze Stapel oder gar nichts davon: eine Datenbanktransaktion oder eine temporäre Datei, die die alte ersetzt, sobald sie vollständig ist.
5. **Zurücklesen.** Öffnen Sie die Datei mit dem Reader, den der Empfänger nutzt (Excel, pandas, `json.loads`), bevor Sie ihr vertrauen.

Schritt 2 lassen die meisten Skripte aus, und genau daher kommen die Duplikate.

## CSV, JSON Lines und SQLite im Vergleich

| | CSV | JSON (ein Array) | JSON Lines | SQLite |
|---|---|---|---|---|
| Verschachtelte Felder (Tag-Listen) | Abflachen, zum Beispiel `love\|life` | Nativ | Nativ | JSON-Text in einer Spalte, abfragbar mit `json_each` |
| Bei jedem Lauf anhängen | Ja, Kopfzeile nur einmal schreiben | Nein, die schließende `]` muss neu geschrieben werden | Ja, eine Zeile pro Datensatz | Ja, mit Upsert |
| Entfernt Duplikate | Nein | Nein | Nein | Ja, über den Primärschlüssel |
| Öffnet sich in Excel | Ja, mit UTF-8-BOM | Nein | Nein | Nein (erst exportieren) |
| Übersteht Absturz beim Schreiben | Letzte Zeile kann abgeschnitten sein | Datei kann unlesbar werden | Nur die letzte Zeile geht verloren | Ja, Transaktionen werden zurückgerollt |
| Abfrage ohne alles zu laden | Nein | Nein | Zeile für Zeile | Ja, SQL und Indizes |
| Größe für unsere 100 Zitate | 25,9 KB | 42,4 KB (eingerückt) | 35,8 KB | 49,2 KB (mit einem Index) |

Die Größen stammen aus unserem eigenen Testlauf. SQLite ist hier am größten, weil es Daten in festen Seiten zu 4 KB speichert (12 Seiten für diese Tabelle und ihren Index); der Overhead schrumpft, wenn die Tabelle wächst. Zum Vergleich: Dieselben Daten als XLSX waren 17,0 KB groß, da XLSX ein gezipptes Format ist.

## Wie speichern Sie gescrapte Daten in Python als CSV?

Das Modul `csv` der Standardbibliothek kümmert sich um Quoting, Kommas in Feldern und Zeilenumbrüche in Werten mit Anführungszeichen. `DictWriter` ordnet jedes Dict über die Spaltennamen einer Zeile zu:

```python
import csv
from pathlib import Path

FIELDS = ["quote_id", "text", "author", "author_url", "tags", "page", "scraped_at"]

def append_csv(rows, path: Path) -> None:
    new_file = not path.exists() or path.stat().st_size == 0
    with path.open("a", newline="", encoding="utf-8-sig" if new_file else "utf-8") as f:
        writer = csv.DictWriter(f, fieldnames=FIELDS)
        if new_file:
            writer.writeheader()
        for row in rows:
            writer.writerow({**row, "tags": "|".join(row["tags"])})
```

Drei Details in dieser Funktion verhindern die üblichen CSV-Beschwerden:

- **`newline=""`.** Die [Python-Dokumentation zu csv](https://docs.python.org/3/library/csv.html) verlangt es für Reader und Writer. Ohne diese Angabe werden Zeilenumbrüche in Feldern mit Anführungszeichen falsch gelesen, und unter Windows endet jede Zeile mit einem zusätzlichen `\r`, das viele Reader nach jedem Datensatz als Leerzeile darstellen.
- **`utf-8-sig` nur beim ersten Schreiben.** Dieser Codec schreibt eine Byte-Order-Mark (`EF BB BF`) an den Dateianfang; die [codecs-Dokumentation](https://docs.python.org/3/library/codecs.html) beschreibt ihn als die UTF-8-Variante, die Microsoft verwendet. Excel liest diese Markierung und dekodiert die Datei als UTF-8, sodass `\u201c` auch `\u201c` bleibt. Hängt das Skript später an, wechselt es zu reinem `utf-8`, sonst landet eine zweite Markierung mitten in der Datei. Unsere Testdatei enthielt nach zwei Läufen genau eine.
- **Die Kopfzeile nur bei einer neuen Datei.** Wer bei jedem Lauf eine Kopfzeile anhängt, hinterlässt verirrte `quote_id,text,...`-Zeilen in den Daten.

Tags werden mit `|` verbunden, einem Trennzeichen, das in den Werten nie vorkommt.

Wenn Sie eine Datei mit BOM lesen, verwenden Sie wieder `encoding="utf-8-sig"`. Mit reinem `utf-8` kommt der erste Spaltenname als `'\ufeffquote_id'` zurück, und `row["quote_id"]` löst einen `KeyError` aus. Das ist uns im Test passiert; weitere Kodierungsfallen finden Sie unter [Unicode-Kodierungsfehler in Python](/de/blog/python-unicode-encoding-errors).

Mit pandas ist dieselbe Datei eine Zeile, und `drop_duplicates` repariert eine CSV- oder JSON-Lines-Datei, die bereits Wiederholungen enthält:

```python
import pandas as pd

df = pd.read_json("data/quotes.jsonl", lines=True, convert_dates=False)
df = df.drop_duplicates(subset="quote_id", keep="last")
df.assign(tags=df["tags"].str.join("|")).to_csv(
    "data/quotes_clean.csv", index=False, encoding="utf-8-sig")
df.assign(tags=df["tags"].str.join(", ")).to_excel(
    "data/quotes.xlsx", index=False, sheet_name="quotes")  # benötigt openpyxl
```

Ein Excel-Arbeitsblatt fasst laut [Microsofts Spezifikationen und Grenzwerten](https://support.microsoft.com/en-us/office/excel-specifications-and-limits-1672b34d-7043-467e-8e27-269d656771c3) höchstens 1.048.576 Zeilen. Eine CSV-Datei hat keine solche Grenze, aber Excel zeigt nur so viele Zeilen davon an.

## Wie speichern Sie gescrapte Daten als JSON oder JSON Lines?

Ein einzelnes JSON-Array eignet sich für einen Snapshot, den ein anderes Programm in einem Zug lädt. Schreiben Sie es mit `ensure_ascii=False`, damit nicht englischer Text lesbar bleibt, statt zu `\u201c`-Escapes zu werden, und schreiben Sie atomar: erst in eine temporäre Datei im selben Ordner, dann mit `os.replace` austauschen. Bricht der Prozess mittendrin ab, bleibt die alte Datei intakt.

```python
import json, os, tempfile
from pathlib import Path

def write_json_atomic(data, path: Path) -> None:
    fd, tmp = tempfile.mkstemp(dir=path.parent, suffix=".tmp")
    with os.fdopen(fd, "w", encoding="utf-8") as f:
        json.dump(data, f, ensure_ascii=False, indent=2)
    os.replace(tmp, path)
```

Die Schwäche eines JSON-Arrays ist das Anhängen. Die Datei endet mit `]`, also bedeutet ein zusätzlicher Datensatz, die ganze Datei zu lesen und neu zu schreiben. [JSON Lines](https://jsonlines.org/) vermeidet das: Jede Zeile ist ein vollständiger JSON-Wert, die Datei ist UTF-8, und Zeilen enden mit `\n`. Anhängen ist ein einfacher Schreibvorgang, und ein Absturz kann nur die letzte Zeile beschädigen:

```python
def append_jsonl(rows, path: Path) -> None:
    with path.open("a", encoding="utf-8") as f:
        for row in rows:
            f.write(json.dumps(row, ensure_ascii=False) + "\n")
```

pandas liest die Datei mit `pd.read_json(path, lines=True)`, und mit `chunksize=` verarbeiten Sie eine große Datei in Stücken. Eine Überraschung aus unserem Test: Laut [Dokumentation zu read_json](https://pandas.pydata.org/docs/reference/api/pandas.read_json.html) werden Spalten, deren Namen auf `_at` oder `_time` enden, standardmäßig als Datum geparst. Unsere Spalte `scraped_at` wurde zu einem zeitzonenbehafteten Datetime, und `to_excel()` brach anschließend mit „Excel does not support datetimes with timezones“ ab (Excel unterstützt keine Datumswerte mit Zeitzone). Übergeben Sie `convert_dates=False`, wenn der Text unverändert bleiben soll.

Lässt sich eine Datei mit `JSONDecodeError` nicht laden, finden Sie die üblichen Ursachen (ein abgebrochener Schreibvorgang, zwei Arrays in einer Datei, JSON Lines als ein Dokument gelesen) unter [JSONDecodeError: Expecting value](/de/blog/jsondecodeerror-expecting-value).

## Wie speichern Sie gescrapte Daten in SQLite ohne Duplikate?

Deklarieren Sie den stabilen Schlüssel als Primärschlüssel und überlassen Sie der Datenbank die Entscheidung zwischen Einfügen und Aktualisieren. SQLite unterstützt das seit Version 3.24.0 mit der Upsert-Klausel; im Teil `DO UPDATE` verweist das spezielle Präfix `excluded.` auf die Werte, die das abgewiesene Insert schreiben wollte ([SQLite-Dokumentation zu UPSERT](https://sqlite.org/lang_upsert.html)).

```sql
CREATE TABLE IF NOT EXISTS quotes (
    quote_id   TEXT PRIMARY KEY,
    text       TEXT NOT NULL,
    author     TEXT NOT NULL,
    author_url TEXT,
    tags       TEXT,              -- JSON-Array
    first_seen TEXT NOT NULL,
    last_seen  TEXT NOT NULL
);

INSERT INTO quotes (quote_id, text, author, author_url, tags, first_seen, last_seen)
VALUES (:quote_id, :text, :author, :author_url, :tags, :scraped_at, :scraped_at)
ON CONFLICT(quote_id) DO UPDATE SET
    tags       = excluded.tags,
    author_url = excluded.author_url,
    last_seen  = excluded.last_seen;
```

`first_seen` wird nur geschrieben, wenn die Zeile neu ist; `last_seen` rückt bei jedem Lauf vor. Dieses Paar liefert Ihnen eine inkrementelle Historie gratis: Eine Zeile, deren `last_seen` älter ist als der letzte Lauf, ist von der Website verschwunden, und eine Zeile, deren `first_seen` dem letzten Lauf entspricht, ist neu. Preis-Tracker und Änderungsmonitore beruhen genau auf diesem Muster.

Zwei Läufe des vollständigen Skripts unten gaben aus:

```text
scraped 100 rows: 100 new, 0 already in the database
scraped 100 rows: 0 new, 100 already in the database
```

Die CSV- und JSON-Lines-Dateien derselben zwei Läufe enthielten je 200 Datensätze. Die Datenbank enthielt 100.

Sobald die Daten in SQLite liegen, werden Fragen zu Abfragen. Diese liefen gegen unsere Testdatenbank:

```python
import sqlite3

con = sqlite3.connect("data/quotes.db")

# Autoren mit den meisten Zitaten
for author, n in con.execute(
        "SELECT author, COUNT(*) AS n FROM quotes GROUP BY author ORDER BY n DESC LIMIT 3"):
    print(author, n)

# Zitate mit dem Tag "love" (Tags sind als JSON-Array gespeichert)
print(con.execute("""
    SELECT COUNT(*) FROM quotes, json_each(quotes.tags)
    WHERE json_each.value = 'love'""").fetchone()[0])

# Zeilen, die beim letzten Lauf nicht auf der Website standen
print(con.execute("""
    SELECT COUNT(*) FROM quotes
    WHERE last_seen < (SELECT MAX(last_seen) FROM quotes)""").fetchone()[0])
con.close()
```

```text
Albert Einstein 10
J.K. Rowling 9
Marilyn Monroe 7
14
0
```

Für Analysen liefert `pd.read_sql_query(sql, con)` das Ergebnis als DataFrame. Die Bereinigungsschritte, die meist folgen, beschreibt [Gescrapte Daten mit pandas bereinigen](/de/blog/clean-scraped-data-with-pandas).

## Vollständiges Skript: scrapen, dann CSV, JSON Lines und SQLite schreiben

Das Skript folgt dem Link „Next“ der Website, bis er verschwindet, wiederholt Anfragen bei 429- und 5xx-Antworten mit exponentiellem Backoff (urllib3s `Retry` beachtet auch einen `Retry-After`-Header), wartet zwischen den Seiten eine Sekunde und schreibt pro Lauf einen einzigen Zeitstempel, damit die `last_seen`-Vergleiche funktionieren. quotes.toscrape.com hat 10 Seiten und 100 Zitate.

```bash
pip install requests beautifulsoup4 lxml pandas openpyxl
```

```python
"""Scrapt quotes.toscrape.com und speichert die Zeilen als CSV, JSON Lines und in SQLite."""
import csv
import hashlib
import json
import sqlite3
import time
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import urljoin

import requests
from bs4 import BeautifulSoup
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

BASE = "https://quotes.toscrape.com/"
FIELDS = ["quote_id", "text", "author", "author_url", "tags", "page", "scraped_at"]

def make_session() -> requests.Session:
    retry = Retry(total=4, backoff_factor=1,
                  status_forcelist=[429, 500, 502, 503, 504],
                  allowed_methods=["GET"])
    session = requests.Session()
    session.mount("https://", HTTPAdapter(max_retries=retry))
    session.headers["User-Agent"] = "quotes-storage-demo/1.0 (contact: you@example.com)"
    return session

def quote_key(text: str, author: str) -> str:
    # Die Website hat keine Zitat-IDs, daher leiten wir einen stabilen Schlüssel aus dem Inhalt ab.
    return hashlib.sha1(f"{author}\n{text}".encode("utf-8")).hexdigest()[:16]

def scrape(session: requests.Session, delay: float = 1.0):
    url, page = BASE, 1
    run_at = datetime.now(timezone.utc).isoformat(timespec="seconds")  # ein Zeitstempel pro Lauf
    while url:
        resp = session.get(url, timeout=20)
        resp.raise_for_status()
        soup = BeautifulSoup(resp.content, "lxml")
        for q in soup.select("div.quote"):
            text = q.select_one("span.text").get_text(strip=True)
            author = q.select_one("small.author").get_text(strip=True)
            yield {
                "quote_id": quote_key(text, author),
                "text": text,
                "author": author,
                "author_url": urljoin(BASE, q.select_one("span a")["href"]),
                "tags": [t.get_text(strip=True) for t in q.select("a.tag")],
                "page": page,
                "scraped_at": run_at,
            }
        nxt = soup.select_one("li.next a")
        url = urljoin(url, nxt["href"]) if nxt else None
        page += 1
        time.sleep(delay)

def append_csv(rows, path: Path) -> None:
    new_file = not path.exists() or path.stat().st_size == 0
    with path.open("a", newline="", encoding="utf-8-sig" if new_file else "utf-8") as f:
        writer = csv.DictWriter(f, fieldnames=FIELDS)
        if new_file:
            writer.writeheader()
        for row in rows:
            writer.writerow({**row, "tags": "|".join(row["tags"])})

def append_jsonl(rows, path: Path) -> None:
    with path.open("a", encoding="utf-8") as f:
        for row in rows:
            f.write(json.dumps(row, ensure_ascii=False) + "\n")

SCHEMA = """
CREATE TABLE IF NOT EXISTS quotes (
    quote_id   TEXT PRIMARY KEY,
    text       TEXT NOT NULL,
    author     TEXT NOT NULL,
    author_url TEXT,
    tags       TEXT,              -- JSON-Array
    first_seen TEXT NOT NULL,
    last_seen  TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_quotes_author ON quotes(author);
"""

UPSERT = """
INSERT INTO quotes (quote_id, text, author, author_url, tags, first_seen, last_seen)
VALUES (:quote_id, :text, :author, :author_url, :tags, :scraped_at, :scraped_at)
ON CONFLICT(quote_id) DO UPDATE SET
    tags       = excluded.tags,
    author_url = excluded.author_url,
    last_seen  = excluded.last_seen
"""

def save_sqlite(rows, path: Path) -> tuple[int, int]:
    con = sqlite3.connect(path)
    try:
        con.executescript(SCHEMA)
        before = con.execute("SELECT COUNT(*) FROM quotes").fetchone()[0]
        with con:  # eine Transaktion: Commit bei Erfolg, Rollback bei Fehler
            con.executemany(UPSERT, [{**r, "tags": json.dumps(r["tags"])} for r in rows])
        after = con.execute("SELECT COUNT(*) FROM quotes").fetchone()[0]
        return after - before, len(rows) - (after - before)
    finally:
        con.close()

if __name__ == "__main__":
    out = Path("data")
    out.mkdir(exist_ok=True)
    rows = list(scrape(make_session()))
    append_csv(rows, out / "quotes.csv")
    append_jsonl(rows, out / "quotes.jsonl")
    new, seen = save_sqlite(rows, out / "quotes.db")
    print(f"scraped {len(rows)} rows: {new} new, {seen} already in the database")
```

`with con:` fasst den Upsert in eine Transaktion: Schlägt eine Zeile fehl, wird keine geschrieben. Die Verbindung schließt es nicht, das übernimmt der `finally`-Block. Benannte Platzhalter (`:quote_id`) erlauben es `executemany`, dieselben Dicts zu verwenden wie der CSV-Writer, und verhindern, dass Apostrophe das SQL zerbrechen.

Für eine echte Website behalten Sie die Struktur bei und ändern Selektoren, Schlüssel und Wartezeit. Prüfen Sie vorher die Nutzungsbedingungen und die `robots.txt` der Website ([robots.txt erklärt](/de/blog/robots-txt)), und nutzen Sie eine offizielle API, wenn es eine gibt. Dieselbe Wartezeit von einer Sekunde und dieselben Retry-Einstellungen halten einen Job fern von [HTTP-429-Fehlern](/de/blog/http-429-too-many-requests). Wächst ein Scraper zu einem geplanten Crawler, der seine Anfragen über mehrere Exit-IPs verteilt, lassen sich [Rotierender Proxy](https://proxynet.io/de/rotating-proxy) und [Residential-Proxy](https://proxynet.io/de/residential-proxy) über die Einstellung `proxies` in dieselbe `requests.Session` einbinden; der Speichercode ändert sich nicht.

## Wann sollten Sie von SQLite zu PostgreSQL wechseln?

Der [Leitfaden des SQLite-Projekts](https://www.sqlite.org/whentouse.html) benennt die Grenzen klar: SQLite unterstützt pro Datenbankdatei nur einen Schreiber gleichzeitig und ist die falsche Wahl, wenn Daten und Anwendung auf verschiedenen Rechnern liegen. Für ein Scraping-Projekt ergeben sich daraus drei Signale:

- **Mehrere Worker schreiben gleichzeitig.** Crawler-Prozesse auf drei Servern brauchen einen Datenbankserver; einige Prozesse auf einem Rechner können sich abwechseln.
- **Die Datenbank muss über das Netzwerk erreichbar sein.** Ein Dashboard, eine API und ein Scraper auf getrennten Hosts sollten sich PostgreSQL teilen, nicht eine `.db`-Datei auf einer Netzwerkfreigabe.
- **Andere Personen brauchen Berechtigungen.** Benutzer, Rollen und reiner Lesezugriff sind Serverfunktionen.

Die Größe allein ist selten der Auslöser: Das dokumentierte Maximum liegt bei etwa 281 TB, weit über dem, was ein einzelner Scraper erzeugt. Der Umzug selbst ist klein. PostgreSQL verwendet dieselbe Syntax `INSERT ... ON CONFLICT (key) DO UPDATE` mit `EXCLUDED`, der Upsert oben lässt sich also fast unverändert übernehmen. Pipelines, die zu mehreren Stufen wachsen, behandelt [Was ist ETL?](/de/blog/what-is-etl).

## Anwendungsfälle

- **Preisüberwachung:** Eine Zeile pro Produkt und Tag, mit SKU und Datum als Schlüssel, trägt die [Preisüberwachung](/de/price-monitoring) und das [Tracking von Wettbewerberpreisen](/de/blog/competitor-price-tracking).
- **Änderungserkennung:** `first_seen` und `last_seen` zeigen neue und entfernte Einträge, der Kern der [Überwachung von Website-Änderungen](/de/blog/website-change-monitoring).
- **Forschungsdatensätze:** JSON-Lines-Dateien lassen sich leicht an Analysten weitergeben und für die [Marktforschung](/de/market-research) in Notebooks laden.
- **Große Crawls:** Ein [Web-Crawler](/de/web-crawler), der Tausende URLs besucht, kann seine Frontier und seine Ergebnisse in SQLite speichern, wie in [Einen Web-Crawler mit Python bauen](/de/blog/python-web-crawler).
- **Wiederkehrende Scraping-Jobs:** Geplante Läufe für [Data Scraping](/de/data-scraping), die die Zeilen von gestern nicht verdoppeln dürfen.

## Häufige Fehler

- **Kein stabiler Schlüssel.** Ohne ihn hängt jeder Lauf den vollständigen Datensatz erneut an. Nutzen Sie die ID der Website oder bilden Sie einen Hash aus den Feldern, die einen Datensatz identifizieren.
- **CSV-Dateien ohne `newline=""` öffnen.** Zusätzliche `\r`-Zeichen unter Windows und zerbrochene mehrzeilige Felder.
- **Reines `utf-8` für eine CSV, die in Excel geöffnet werden soll.** Umlaute, Akzente und typografische Zeichen werden zu Folgen wie `Ã©`.
- **Die Ausgabedatei direkt überschreiben.** Ein Absturz hinterlässt eine halbe Datei. Schreiben Sie in eine temporäre Datei und tauschen Sie sie mit `os.replace` aus.
- **Ein Commit pro Zeile in SQLite.** Jeder Commit synchronisiert auf die Festplatte. Fassen Sie einen Stapel mit `executemany` in eine Transaktion.
- **SQL mit f-Strings zusammenbauen.** Ein Zitat mit Apostroph zerbricht die Anweisung. Verwenden Sie Platzhalter.
- **Bei einem großen Crawl alles im Speicher halten.** Schreiben Sie pro Seite oder pro Stapel, damit ein Absturz eine Seite kostet und nicht den ganzen Lauf.

## Entscheidungshilfe

| Bedarf | Empfehlung |
|---|---|
| Eine Datei, die eine Kollegin in Excel öffnet | CSV mit `utf-8-sig` oder XLSX über pandas |
| Verschachtelte Datensätze für ein anderes Programm | JSON (ein Array), atomar geschrieben |
| Ein fortlaufendes Log aller Läufe | JSON Lines |
| Wiederholte Läufe ohne Duplikate | SQLite mit Primärschlüssel und Upsert |
| Historie: wann wurde jeder Eintrag zuerst und zuletzt gesehen | SQLite mit `first_seen` und `last_seen` |
| Mehrere Rechner schreiben gleichzeitig | PostgreSQL mit demselben `ON CONFLICT`-Upsert |
| Ad-hoc-Analyse | Jede der obigen Varianten, in pandas geladen |

## Häufige Fragen

### Welches Format eignet sich am besten zum Speichern gescrapter Daten?

Das hängt davon ab, wer die Daten liest: CSV für Tabellenkalkulationen, JSON Lines für verschachtelte Datensätze und fortlaufende Logs, SQLite für geplante Scraper, die keine Zeilen verdoppeln dürfen. Viele Projekte halten SQLite als maßgebliche Quelle und exportieren CSV für Menschen.

### Warum sieht meine gescrapte CSV in Excel verstümmelt aus?

Hat eine CSV-Datei keine Byte-Order-Mark, dekodiert Excel unter Windows sie oft mit der alten Codepage des Systems statt mit UTF-8, sodass jedes Mehrbyte-Zeichen zu zwei oder drei falschen wird. Schreiben Sie die Datei mit `encoding="utf-8-sig"` oder importieren Sie sie über den Datenimport von Excel und wählen Sie UTF-8.

### Wie hänge ich gescrapte Daten an eine bestehende CSV an, ohne die Kopfzeile zu wiederholen?

Öffnen Sie die Datei im Modus `"a"` und schreiben Sie die Kopfzeile nur, wenn die Datei neu oder leer ist, wie es `append_csv` oben tut. Anhängen entfernt keine Duplikate; dafür laden Sie die Datei mit pandas und rufen `drop_duplicates` auf oder speichern die Daten mit einem Schlüssel in SQLite.

### Was ist der Unterschied zwischen JSON und JSON Lines?

Eine JSON-Datei enthält einen einzigen Wert, meist ein Array von Datensätzen, und muss daher als Ganzes gelesen und geschrieben werden. JSON Lines enthält einen JSON-Wert pro Zeile, sodass Sie einen Datensatz mit einem einzigen Schreibvorgang anhängen und eine große Datei Zeile für Zeile lesen können.

### Kommt SQLite mit einem großen Scraping-Projekt zurecht?

Auf einem Rechner meistens ja; die Größengrenze liegt weit über dem, was ein Scraper erzeugt. Entscheidend ist die Nebenläufigkeit: ein Schreiber gleichzeitig pro Datei. Schreiben mehrere Rechner gleichzeitig, brauchen Sie PostgreSQL.

### Wie vermeide ich doppelte Zeilen, wenn ich einen Scraper erneut ausführe?

Geben Sie jedem Datensatz einen stabilen Schlüssel, machen Sie ihn in SQLite zum Primärschlüssel und fügen Sie mit `ON CONFLICT(key) DO UPDATE` ein. Bei flachen Dateien entfernen Sie Duplikate nach dem Laden mit `drop_duplicates(subset="key")`.

## Fazit

Die Speicherung entscheidet darüber, ob ein Scraper nach seinem ersten Lauf nützlich bleibt. CSV mit `utf-8-sig` und `newline=""` ist die Übergabe an Tabellenkalkulationen, JSON Lines der einfachste sichere Weg, strukturierte Datensätze anzuhängen. SQLite mit stabilem Schlüssel und Upsert macht aus wiederholten Läufen eine abfragbare Tabelle mit Historie zu erstem und letztem Auftreten; PostgreSQL übernimmt, sobald mehrere Rechner gleichzeitig schreiben. Wachsen Ihre Läufe so weit, dass eine einzelne IP-Adresse nicht mehr reicht, um die Daten in maßvollem Tempo zu sammeln, finden Sie die passenden [Proxy-Tarife](/de/proxy) von Proxynet.
