Soft Deletes richtig modellieren
AI generated
SELECT
JOIN
SQL · Datenmodellierung · Soft Deletes
Soft Deletes richtig modellieren
deleted_at, Status-Spalte oder Archiv-Tabelle

Ein Soft Delete markiert eine Zeile als geloescht, ohne sie physisch zu entfernen, klingt nach einer einzigen zusaetzlichen Spalte und entpuppt sich in der Praxis als Quelle fuer Unique-Constraint-Konflikte, aufgeblaehte Indizes und vergessene WHERE-Bedingungen. Dieser Beitrag zeigt drei etablierte Modelle fuer Soft Deletes und ordnet ein, wann welches wirklich passt.

16 Min. Lesezeit deleted_at · Partial Index · Status-Spalte · Archiv-Tabelle PostgreSQL · MySQL · SQL Server

1. Was ein Soft Delete ist und warum Hard Delete oft die falsche Wahl ist

Ein Soft Delete markiert eine Zeile als geloescht, ohne sie physisch aus der Tabelle zu entfernen. Statt DELETE FROM orders WHERE order_id = 42 setzt die Anwendung ein Flag oder einen Zeitstempel, und ein Update ersetzt den destruktiven Vorgang. Die Zeile bleibt vollstaendig erhalten, inklusive aller Beziehungen zu anderen Tabellen, nur die Anwendungslogik behandelt sie ab diesem Zeitpunkt als nicht mehr existent.

Der Grund, warum Soft Delete in vielen Systemen dem echten Hard Delete vorgezogen wird, ist selten Bequemlichkeit, sondern echte fachliche Anforderung: Ein Kunde soll seine Bestellhistorie sehen koennen, auch wenn ein Produkt daraus geloescht wurde. Ein Compliance-Team braucht rueckwirkend nachvollziehbare Daten. Eine Undo-Funktion soll versehentlich geloeschte Datensaetze innerhalb einer Frist wiederherstellen koennen. Ein Hard Delete zerstoert genau diese Moeglichkeiten unwiderruflich, sobald der Datenbanktransaktion committet ist.

Gleichzeitig ist Soft Delete keine triviale Ein-Zeilen-Loesung. Sobald eine als geloescht markierte Zeile weiterhin in derselben Tabelle wie aktive Zeilen liegt, muessen Unique Constraints, Indizes, Fremdschluessel-Kaskaden und praktisch jede Abfrage in der gesamten Anwendung diese neue Realitaet beruecksichtigen. Die folgenden Abschnitte zeigen, welche Modelle sich in der Praxis bewaehrt haben und wo die typischen Fallstricke liegen.

2. Das deleted_at-Timestamp-Muster

Das verbreitetste Muster fuer Soft Delete ist eine nullable Spalte deleted_at vom Typ Timestamp. Solange der Wert NULL ist, gilt die Zeile als aktiv. Sobald ein Zeitstempel gesetzt wird, gilt die Zeile als geloescht, und der Zeitstempel selbst dokumentiert zusaetzlich, wann genau die Loeschung stattfand, was fuer Audits und Support-Anfragen oft genauso wichtig ist wie die Tatsache der Loeschung selbst.


-- Klassisches deleted_at-Muster fuer Soft Delete
CREATE TABLE orders (
  order_id     SERIAL PRIMARY KEY,
  customer_id  INTEGER NOT NULL REFERENCES customers(customer_id),
  total_amount NUMERIC(10,2) NOT NULL,
  created_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
  deleted_at   TIMESTAMPTZ NULL           -- NULL = aktiv, gesetzt = geloescht
);

-- Soft Delete statt DELETE
UPDATE orders SET deleted_at = now() WHERE order_id = 42;

-- Aktive Bestellungen abfragen
SELECT * FROM orders WHERE deleted_at IS NULL;

-- Wiederherstellen: einfach das Flag zuruecksetzen
UPDATE orders SET deleted_at = NULL WHERE order_id = 42;

Der Vorteil dieses Musters liegt in seiner Einfachheit und der zusaetzlichen Information im Zeitstempel selbst. Nachteile zeigen sich erst bei genauerem Hinsehen: Jede einzelne Abfrage gegen diese Tabelle muss konsequent WHERE deleted_at IS NULL ergaenzen, sonst tauchen geloeschte Zeilen unbeabsichtigt in Ergebnissen auf. Viele ORMs bieten dafuer einen globalen Scope an, der das automatisch erledigt, was Disziplin ersetzt, aber auch eigene Fehlerquellen mitbringt, etwa wenn Rohabfragen den Scope umgehen.

3. Unique Constraints und Soft Deletes: das Konfliktproblem

Ein Soft Delete kollidiert fast zwangslaeufig mit Unique Constraints, sobald ein fachlich eindeutiger Wert wie eine E-Mail-Adresse oder ein Produktcode betroffen ist. Ein klassischer Standard-Unique-Index kennt keinen Unterschied zwischen aktiven und geloeschten Zeilen, er verhindert schlicht doppelte Werte in der gesamten Spalte. Wird ein Benutzerkonto per Soft Delete entfernt, bleibt die E-Mail-Adresse in der geloeschten Zeile stehen und blockiert eine erneute Registrierung mit derselben Adresse.


-- Problem: normaler Unique-Constraint blockiert Neu-Registrierung
-- nach Soft Delete des alten Kontos
CREATE TABLE users (
  user_id    SERIAL PRIMARY KEY,
  email      VARCHAR(255) NOT NULL UNIQUE,   -- greift auch bei geloeschten Zeilen
  deleted_at TIMESTAMPTZ NULL
);

-- Nutzer wird per Soft Delete entfernt
UPDATE users SET deleted_at = now() WHERE user_id = 7;

-- Neuregistrierung mit derselben E-Mail schlaegt fehl,
-- obwohl das alte Konto laengst "geloescht" ist
INSERT INTO users (email) VALUES ('kunde@example.com');
-- ERROR: duplicate key value violates unique constraint "users_email_key"

Dieses Verhalten ist nicht immer unerwuenscht, in manchen Domaenen soll eine einmal genutzte E-Mail-Adresse dauerhaft blockiert bleiben, etwa aus Betrugspraevention. Ueberall dort, wo eine Wiederverwendung fachlich zulaessig sein soll, braucht es jedoch eine Loesung, die den Unique Constraint nur auf aktive Zeilen anwendet, nicht auf die gesamte Tabelle inklusive aller per Soft Delete markierten Historie.

4. Partial Indexes fuer aktive Datensaetze

Die sauberste Loesung fuer das Unique-Constraint-Problem ist ein Partial Index, also ein Index, der nur einen Teil der Zeilen einer Tabelle abdeckt, definiert ueber eine WHERE-Bedingung im Index selbst. PostgreSQL und SQLite unterstuetzen dieses Feature nativ, MySQL und aeltere SQL-Server-Versionen benoetigen Workarounds ueber berechnete Spalten oder gefilterte Indizes mit aehnlicher Syntax.


-- PostgreSQL: Partial Unique Index nur fuer aktive Zeilen
CREATE UNIQUE INDEX idx_users_email_active
  ON users (email)
  WHERE deleted_at IS NULL;

-- Jetzt ist die Wiederverwendung nach Soft Delete moeglich
UPDATE users SET deleted_at = now() WHERE user_id = 7;
INSERT INTO users (email) VALUES ('kunde@example.com');  -- funktioniert

-- SQL Server: aequivalent per Filtered Index
-- CREATE UNIQUE INDEX idx_users_email_active
--   ON users (email)
--   WHERE deleted_at IS NULL;

-- MySQL (kein natives Partial Index): Workaround ueber generierte Spalte
-- ALTER TABLE users ADD COLUMN email_active VARCHAR(255)
--   GENERATED ALWAYS AS (CASE WHEN deleted_at IS NULL THEN email ELSE NULL END) STORED;
-- CREATE UNIQUE INDEX idx_users_email_active ON users (email_active);

Neben der Loesung des Unique-Problems bringen Partial Indexes einen weiteren Vorteil: Sie sind kleiner und schneller als ein Index ueber die gesamte Tabelle, weil geloeschte Zeilen, die in vielen Systemen mit der Zeit die Mehrheit der Datensaetze ausmachen, gar nicht erst indexiert werden. Abfragen, die konsequent nach aktiven Zeilen filtern, profitieren direkt von dieser kleineren Indexgroesse, ohne dass sich am Abfrage-Code etwas aendern muss.

5. Soft Deletes und Fremdschluessel: kaskadierende Effekte

Ein oft uebersehener Aspekt von Soft Delete betrifft Fremdschluessel-Beziehungen. Ein echtes DELETE mit ON DELETE CASCADE raeumt automatisch alle abhaengigen Zeilen mit auf. Ein Soft Delete per UPDATE loest keinerlei Kaskaden aus, weil aus Sicht der Datenbank ueberhaupt keine Loeschung stattfindet, lediglich ein gewoehnliches Update einer Spalte. Abhaengige Zeilen in anderen Tabellen bleiben vollstaendig unberuehrt und damit unsichtbar als "aktiv" markiert, obwohl ihr Elterndatensatz logisch bereits verschwunden ist.

In der Praxis bedeutet das: Wird eine Bestellung per Soft Delete entfernt, bleiben ihre Bestellpositionen unveraendert aktiv, sofern die Anwendung nicht explizit dafuer sorgt, dass abhaengige Tabellen ebenfalls einen eigenen Soft Delete erhalten. Ein sauberer Ansatz ist ein Trigger, der beim Setzen von deleted_at in der Elterntabelle denselben Zeitstempel in allen abhaengigen Tabellen propagiert, statt sich auf verstreuten Anwendungscode zu verlassen, der leicht vergessen wird.

6. Alternative: Status-Spalte statt Timestamp

Statt eines einzelnen deleted_at-Zeitstempels modellieren manche Systeme den Zustand einer Zeile ueber eine generische Status-Spalte, etwa als Enum mit Werten wie active, archived und deleted. Dieses Muster lohnt sich, sobald eine Zeile mehr als zwei sinnvolle Zustaende durchlaeuft, zum Beispiel wenn zwischen einem vom Nutzer archivierten und einem tatsaechlich geloeschten Datensatz unterschieden werden soll.


-- Status-Spalte statt reinem Soft-Delete-Flag
CREATE TYPE record_status AS ENUM ('active', 'archived', 'deleted');

CREATE TABLE documents (
  document_id  SERIAL PRIMARY KEY,
  title        VARCHAR(255) NOT NULL,
  status       record_status NOT NULL DEFAULT 'active',
  status_changed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Archivieren statt Loeschen
UPDATE documents SET status = 'archived', status_changed_at = now()
WHERE document_id = 15;

-- Nur aktive Dokumente anzeigen
SELECT * FROM documents WHERE status = 'active';

-- Partial Index bleibt weiterhin sinnvoll fuer Unique-Faelle
CREATE UNIQUE INDEX idx_documents_title_active
  ON documents (title) WHERE status = 'active';

Der Vorteil einer Status-Spalte gegenueber dem reinen Timestamp-Flag ist die Ausdrucksstaerke: Mehr als zwei Zustaende lassen sich abbilden, ohne mehrere Boolean- oder Timestamp-Spalten zu kombinieren. Der Nachteil ist zusaetzliche Komplexitaet in der Anwendungslogik, weil jede Statusaenderung als expliziter Zustandsuebergang behandelt werden sollte, idealerweise mit einer Regel, welche Uebergaenge erlaubt sind und welche nicht.

7. Alternative: Archiv-Tabelle statt Flag

Die dritte Alternative verzichtet vollstaendig auf ein Flag in der Haupttabelle und verschiebt geloeschte Zeilen stattdessen physisch in eine separate Archiv-Tabelle mit identischer Struktur. Ein Soft Delete wird dabei technisch zu einem INSERT in die Archiv-Tabelle gefolgt von einem echten DELETE in der Haupttabelle, meist innerhalb einer einzigen Transaktion oder per Trigger automatisiert.


-- Archiv-Tabelle mit identischer Struktur plus Metadaten
CREATE TABLE orders_archive (
  LIKE orders INCLUDING ALL,
  archived_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
  archived_by  INTEGER REFERENCES users(user_id)
);

-- "Soft Delete" als Verschiebung in einer Transaktion
BEGIN;
INSERT INTO orders_archive
SELECT o.*, now(), 42 FROM orders o WHERE o.order_id = 7781;

DELETE FROM orders WHERE order_id = 7781;
COMMIT;

-- Haupttabelle bleibt schlank, keine deleted_at-Filterung noetig
SELECT * FROM orders;  -- enthaelt garantiert nur aktive Bestellungen

Der grosse Vorteil dieses Modells: Die Haupttabelle bleibt garantiert schlank und frei von geloeschten Zeilen, sodass keine Abfrage jemals ein Filter-Praedikat vergessen kann, weil geloeschte Daten schlicht nicht mehr dort liegen. Der Nachteil ist erhoehte Komplexitaet bei Wiederherstellungen, die jetzt eine echte Rueckverschiebung erfordern, sowie doppelter Pflegeaufwand fuer Schema-Aenderungen, weil Haupttabelle und Archiv-Tabelle synchron gehalten werden muessen.

8. Anwendungsseitige Fallstricke: die vergessene WHERE-Bedingung

Der haeufigste Fehler bei Soft Delete mit dem deleted_at- oder Status-Muster ist schlicht eine vergessene Filterbedingung. Ein neuer Report, ein manuelles Analytics-Query oder eine neue API-Route, die direkt gegen die Tabelle schreibt, statt den ORM-Scope zu nutzen, zeigt ploetzlich geloeschte Datensaetze in Auswertungen, Exporten oder sogar in oeffentlichen Listenansichten. Solche Fehler sind besonders tueckisch, weil sie keinen Absturz verursachen, sondern nur leicht falsche Zahlen produzieren.

Ein zweiter haeufiger Fehler betrifft Aggregationen: Eine COUNT(*)-Abfrage ohne Filterbedingung zaehlt automatisch auch geloeschte Zeilen mit, was Dashboards und KPI-Reports systematisch verfaelscht. Datenbank-Views, die den Filter WHERE deleted_at IS NULL fest einbauen und als einzige Schnittstelle fuer Reporting-Tools dienen, reduzieren dieses Risiko erheblich, weil die Filterlogik an einer einzigen Stelle zentralisiert wird statt in jeder einzelnen Abfrage wiederholt zu werden.

9. Soft-Delete-Modelle im Vergleich

Die folgende Tabelle vergleicht die drei besprochenen Modelle fuer Soft Delete nach Implementierungsaufwand, Abfragekomplexitaet und Eignung fuer Wiederherstellungen.

Modell Implementierungsaufwand Abfragekomplexitaet Wiederherstellung
deleted_at Timestamp Gering Filter in jeder Abfrage noetig Sofort, Flag zuruecksetzen
Status-Spalte Mittel Filter noetig, mehr Zustaende moeglich Sofort, Status aendern
Archiv-Tabelle Hoch Kein Filter in Haupttabelle noetig Aufwendig, echte Rueckverschiebung

10. Zusammenfassung

Soft Delete loest ein reales fachliches Beduerfnis, naemlich Daten fuer Historie, Compliance oder Undo-Funktionen zu erhalten, ohne sie physisch zu entfernen. Das populaerste Muster, eine nullable deleted_at-Spalte, ist einfach umzusetzen, bringt aber Unique-Constraint-Konflikte und das Risiko vergessener Filterbedingungen mit sich, die sich mit Partial Indexes und zentralisierten Datenbank-Views entschaerfen lassen.

Eine Status-Spalte lohnt sich, sobald mehr als zwei Zustaende noetig sind, eine Archiv-Tabelle lohnt sich, sobald die Haupttabelle unter keinen Umstaenden geloeschte Zeilen enthalten darf. Alle drei Modelle teilen dieselbe Grundregel: Soft Delete ohne durchdachte Indexierung und ohne zentralisierte Filterlogik fuehrt fast zwangslaeufig zu Dateninkonsistenzen, die erst bei genauem Hinsehen auffallen.

Soft Deletes richtig modellieren, das Wichtigste auf einen Blick

deleted_at Timestamp

Einfachstes Muster, aber jede Abfrage muss WHERE deleted_at IS NULL konsequent ergaenzen.

Unique Constraints

Ein Partial Index mit WHERE deleted_at IS NULL loest Konflikte bei Wiederverwendung eindeutiger Werte.

Status-Spalte

Sinnvoll bei mehr als zwei Zustaenden, etwa active, archived und deleted statt einem reinen Flag.

Archiv-Tabelle

Haelt die Haupttabelle garantiert frei von geloeschten Zeilen, kostet aber Aufwand bei Wiederherstellungen.

11. FAQ: Soft Deletes richtig modellieren

1Was ist ein Soft Delete?
Eine Zeile wird als geloescht markiert, ohne sie physisch zu entfernen, meist ueber Zeitstempel oder Status-Spalte.
2Warum nicht immer Hard Delete?
Hard Delete zerstoert Daten unwiderruflich, das macht Historie, Compliance und Undo unmoeglich.
3Warum kollidiert es mit Unique Constraints?
Ein Standard-Unique-Index unterscheidet nicht zwischen aktiven und geloeschten Zeilen und blockiert Wiederverwendung.
4Was ist ein Partial Index?
Ein Index nur ueber einen Teil der Zeilen per WHERE-Bedingung, erzwingt Eindeutigkeit nur unter aktiven Zeilen.
5Loest Soft Delete FK-Kaskaden aus?
Nein, es ist ein UPDATE, keine Loeschung. Abhaengige Tabellen brauchen eigene Soft-Delete-Logik.
6Wann lohnt sich eine Status-Spalte?
Sobald mehr als zwei Zustaende noetig sind, etwa aktiv, archiviert und geloescht.
7Wann lohnt sich eine Archiv-Tabelle?
Wenn die Haupttabelle nie geloeschte Zeilen enthalten darf, etwa aus Performance-Gruenden.
8Was ist der haeufigste Fehler?
Eine vergessene WHERE-deleted_at-IS-NULL-Bedingung in einem neuen Report oder einer Rohabfrage.
9Wie vermeidet man das zuverlaessig?
Ueber Datenbank-Views mit fest eingebautem Filter als einzige Schnittstelle fuer Reporting-Tools.
10Kann man Soft-Delete-Daten fuer die DSGVO endgueltig loeschen?
Ja, ein Batch-Job kann lange geloescht markierte Zeilen nach Ablauf einer Frist per Hard Delete entfernen.