⚡Trigger

BEFORE, AFTER, INSTEAD OF, Zeile vs. Anweisung, NEW/OLD – mit Ablauf-Debugger.

UPDATE artikel SET preis = … WHERE kategorie = 'Büro'   (1 Anweisung, 3 Zeilen)
├─ BEFORE STATEMENT                       PostgreSQL, Oracle
├─ Zeile 1:  BEFORE ROW ─► schreiben ─► AFTER ROW
├─ Zeile 2:  BEFORE ROW ─► schreiben ─► AFTER ROW
├─ Zeile 3:  BEFORE ROW ─► schreiben ─► AFTER ROW
└─ AFTER STATEMENT                        PostgreSQL, Oracle, SQL Server
   (PostgreSQL sammelt AFTER-ROW-Trigger und führt sie am Ende der Anweisung aus)
TriggerPostgreSQLMySQL / MariaDBSQL ServerOracleSQLite
Trigger-ZeitpunkteBEFORE · AFTER · INSTEAD OFBEFORE · AFTERAFTER · INSTEAD OFBEFORE · AFTER · INSTEAD OF · Compound (11g)BEFORE · AFTER · INSTEAD OF (nur Views)
Trigger-EbeneROW und STATEMENTnur ROWnur AnweisungROW und Anweisungnur ROW
alte/neue WerteOLD / NEW · Transition Tables (10)OLD / NEWdeleted / inserted:OLD / :NEWold / new
Trigger ändert eigene Tabelleerlaubt, rekursiv (pg_trigger_depth())verboten (Fehler 1442)RECURSIVE_TRIGGERS, Standard OFFZeilentrigger: ORA-04091PRAGMA recursive_triggers, Standard OFF
Abbruch im TriggerRAISE EXCEPTIONSIGNAL SQLSTATE '45000'THROWRAISE_APPLICATION_ERRORRAISE(ABORT, '…')
⚡ Tipp 1★ EinsteigerBEFORERAISEValidierung

BEFORE-Trigger zur Validierung

😖 Problem
Bestellungen mit Betrag ≤ 0 oder ohne gültigen Status sollen gar nicht erst in die Tabelle gelangen – egal, welche Anwendung schreibt.
💡 Lösung
Ein BEFORE INSERT-Trigger prüft die neue Zeile und bricht mit einer verständlichen Meldung ab. Die erste Bestellung unten geht durch, die zweite wird abgewiesen – die Ausführung endet mit genau dieser Meldung.

🧪 Ausprobieren – Vorher / Nachher

jede Ausführung startet mit frischen Beispieldaten
SQL-Engine wird geladen …

🌍 Dialekte nebeneinander

Ausgeführt werden nur SQLite (sql.js) und – wo markiert – PostgreSQL (PGlite). MySQL/MariaDB, SQL Server und Oracle sind nach der offiziellen Dokumentation geschrieben, laufen hier aber nicht.

PostgreSQL
▶ ausführbar · PGlite
IF NEW.betrag <= 0 THEN
  RAISE EXCEPTION 'Betrag muss positiv sein'
    USING ERRCODE = 'check_violation';
END IF;
RETURN NEW;

Gibt ein BEFORE-Zeilentrigger NULL zurück, wird die Zeile still übersprungen – eine häufige Fehlerquelle.

MySQL / MariaDB
nur gezeigt, nicht ausgeführt
DELIMITER //
CREATE TRIGGER bestellung_pruefen BEFORE INSERT ON bestellungen
FOR EACH ROW
BEGIN
  IF NEW.betrag <= 0 THEN
    SIGNAL SQLSTATE '45000'
      SET MESSAGE_TEXT = 'Betrag muss positiv sein';
  END IF;
END//
DELIMITER ;

Einfacher und seit MySQL 8.0.16 wirksam: CHECK (betrag > 0) – vorher wurden CHECK-Constraints ignoriert.

SQL Server
nur gezeigt, nicht ausgeführt
CREATE OR ALTER TRIGGER bestellung_pruefen ON bestellungen
AFTER INSERT, UPDATE
AS
BEGIN
  IF EXISTS (SELECT 1 FROM inserted WHERE betrag <= 0)
    THROW 50001, 'Betrag muss positiv sein', 1;   -- bricht ab, Transaktion wird zurückgerollt
END;

Kein BEFORE-Trigger in SQL Server: Prüfung im AFTER- (oder INSTEAD OF-) Trigger, für alle Zeilen von inserted gleichzeitig.

Oracle
nur gezeigt, nicht ausgeführt
CREATE OR REPLACE TRIGGER bestellung_pruefen
BEFORE INSERT OR UPDATE ON bestellungen
FOR EACH ROW
BEGIN
  IF :NEW.betrag <= 0 THEN
    RAISE_APPLICATION_ERROR(-20001, 'Betrag muss positiv sein');
  END IF;
END;
/
SQLite
▶ ausgeführt · sql.js
CREATE TRIGGER bestellung_pruefen BEFORE INSERT ON bestellungen
WHEN new.betrag <= 0
BEGIN
  SELECT RAISE(ABORT, 'Betrag muss positiv sein');
END;

RAISE(ABORT|FAIL|ROLLBACK, 'Text') bricht ab, RAISE(IGNORE) überspringt die Zeile still.

⚠️ Fallstricke

  • Was deklarativ geht, gehört in CHECK, NOT NULL, FOREIGN KEY: schneller, für den Optimierer sichtbar und nicht per Trigger-Reihenfolge umgehbar. Trigger erst, wenn andere Tabellen oder komplexe Regeln beteiligt sind.
  • Die Prüfung nur für INSERT anzulegen lässt UPDATE bestellungen SET betrag = -5 durch – immer an UPDATE denken.
  • Im Beispiel bleibt Bestellung 113 gespeichert, weil jede Anweisung einzeln festgeschrieben wird (Autocommit). In einer Transaktion würde ein ROLLBACK beide verwerfen.

📚 Belege

⚡ Tipp 2★★ FortgeschrittenGenerated ColumnAFTER INSERTDenormalisierung

Abgeleitete Werte: Generated Column oder Trigger?

😖 Problem
Eine Positionssumme (menge × einzelpreis) und die Bestellsumme sollen immer stimmen. Die Anwendung vergisst gern mal, sie mitzupflegen.
💡 Lösung
Werte aus derselben Zeile berechnet eine Generated Column – ganz ohne Trigger. Werte über Tabellen hinweg (Bestellsumme, Lagerbestand) pflegt ein AFTER-Trigger. Beachte: Hier gibt es nur INSERT-Trigger – siehe Fallstricke.

🧪 Ausprobieren – Vorher / Nachher

jede Ausführung startet mit frischen Beispieldaten
SQL-Engine wird geladen …

🌍 Dialekte nebeneinander

Ausgeführt werden nur SQLite (sql.js) und – wo markiert – PostgreSQL (PGlite). MySQL/MariaDB, SQL Server und Oracle sind nach der offiziellen Dokumentation geschrieben, laufen hier aber nicht.

PostgreSQLab 12 (Generated Columns)
▶ ausführbar · PGlite
summe numeric(10,2) GENERATED ALWAYS AS (menge * einzelpreis) STORED

-- oder im BEFORE-Trigger NEW verändern:
NEW.summe := NEW.menge * NEW.einzelpreis;
RETURN NEW;

PostgreSQL 18 kennt zusätzlich virtuelle (nicht gespeicherte) Generated Columns.

MySQL / MariaDB
nur gezeigt, nicht ausgeführt
summe DECIMAL(10,2) AS (menge * einzelpreis) STORED

-- im BEFORE-Trigger:
SET NEW.summe = NEW.menge * NEW.einzelpreis;

SET NEW.x wirkt nur in BEFORE-Triggern. Ein Trigger auf positionen darf positionen selbst nicht ändern (Fehler 1442).

SQL Server
nur gezeigt, nicht ausgeführt
summe AS (menge * einzelpreis) PERSISTED   -- berechnete Spalte

-- Bestellsumme mengenbasiert im AFTER-Trigger:
UPDATE b SET betrag = s.summe
  FROM bestellungen b
  JOIN (SELECT bestell_id, SUM(summe) AS summe FROM positionen
         WHERE bestell_id IN (SELECT bestell_id FROM inserted)
         GROUP BY bestell_id) s ON s.bestell_id = b.bestell_id;
Oracle
nur gezeigt, nicht ausgeführt
summe NUMBER(10,2) GENERATED ALWAYS AS (menge * einzelpreis) VIRTUAL

-- im BEFORE-Zeilentrigger:
:NEW.summe := :NEW.menge * :NEW.einzelpreis;

Ein Zeilentrigger auf positionen, der positionen liest (SUM), löst ORA-04091 „mutating table“ aus – Abhilfe: Compound Trigger oder Anweisungstrigger.

SQLiteab 3.31.0 (Generated Columns)
▶ ausgeführt · sql.js
summe NUMERIC(10,2) GENERATED ALWAYS AS (menge * einzelpreis) STORED
-- VIRTUAL (Standard) berechnet beim Lesen, STORED beim Schreiben

In SQLite kann ein Trigger NEW nicht verändern – für zeileninterne Werte ist die Generated Column der Weg.

⚠️ Fallstricke

  • Denormalisierte Summen driften, sobald ein Fall fehlt: Hier gibt es keinen UPDATE- und DELETE-Trigger auf positionen – eine gelöschte Position lässt betrag falsch stehen. Lieber zur Abfragezeit summieren (View) oder alle drei Ereignisse abdecken.
  • Lagerbestand per Trigger heißt: Jede Position sperrt die Artikelzeile bis zum Commit – bei Bestseller-Artikeln ein Engpass.
  • Oracle: Zeilentrigger dürfen die eigene Tabelle nicht lesen (ORA-04091), MySQL: nicht ändern (Fehler 1442).

📚 Belege

⚡ Tipp 3★★ FortgeschrittenINSTEAD OFViewKapselung

INSTEAD OF: Sichten beschreibbar machen

😖 Problem
Die Shop-Oberfläche arbeitet mit Bruttopreisen, gespeichert wird netto. Eine Sicht zeigt preis_brutto – aber ein UPDATE auf eine berechnete Spalte ist nicht möglich.
💡 Lösung
Ein INSTEAD OF UPDATE-Trigger auf der Sicht fängt die Änderung ab und schreibt stattdessen den umgerechneten Nettopreis in die Basistabelle. Die Anwendung merkt davon nichts.

🧪 Ausprobieren – Vorher / Nachher

jede Ausführung startet mit frischen Beispieldaten
SQL-Engine wird geladen …

🌍 Dialekte nebeneinander

Ausgeführt werden nur SQLite (sql.js) und – wo markiert – PostgreSQL (PGlite). MySQL/MariaDB, SQL Server und Oracle sind nach der offiziellen Dokumentation geschrieben, laufen hier aber nicht.

PostgreSQL
▶ ausführbar · PGlite
CREATE TRIGGER artikel_brutto_upd
INSTEAD OF UPDATE ON artikel_brutto
FOR EACH ROW EXECUTE FUNCTION artikel_brutto_upd();

INSTEAD OF nur auf Sichten und nur FOR EACH ROW. Einfache Sichten sind auch ohne Trigger automatisch beschreibbar.

MySQL / MariaDBfehlt – Ersatzweg
nur gezeigt, nicht ausgeführt
-- kein INSTEAD OF in MySQL/MariaDB.
-- Einfache Sichten ohne Berechnung sind direkt beschreibbar;
-- sonst: gespeicherte Prozedur als Schnittstelle
CALL artikel_brutto_setzen('A-301', 35.70);
SQL Server
nur gezeigt, nicht ausgeführt
CREATE OR ALTER TRIGGER artikel_brutto_upd
ON artikel_brutto INSTEAD OF UPDATE
AS
  UPDATE a SET preis = ROUND(i.preis_brutto / 1.19, 2)
    FROM artikel a JOIN inserted i ON i.artikel_nr = a.artikel_nr;

In SQL Server auch auf Tabellen möglich (INSTEAD OF INSERT/UPDATE/DELETE).

Oracle
nur gezeigt, nicht ausgeführt
CREATE OR REPLACE TRIGGER artikel_brutto_upd
INSTEAD OF UPDATE ON artikel_brutto
FOR EACH ROW
BEGIN
  UPDATE artikel SET preis = ROUND(:NEW.preis_brutto / 1.19, 2)
   WHERE artikel_nr = :NEW.artikel_nr;
END;
/
SQLite
▶ ausgeführt · sql.js
CREATE TRIGGER artikel_brutto_upd
INSTEAD OF UPDATE OF preis_brutto ON artikel_brutto
BEGIN
  UPDATE artikel SET preis = ROUND(new.preis_brutto / 1.19, 2)
   WHERE artikel_nr = new.artikel_nr;
END;

INSTEAD OF gibt es in SQLite nur auf Sichten.

⚠️ Fallstricke

  • Rundung: 35,70 € brutto → 30,00 € netto → wieder 35,70 €. Bei anderen Werten kann das Hin- und Zurückrechnen um einen Cent abweichen.
  • Die Sicht verspricht mehr, als der Trigger hält: Ohne INSTEAD OF INSERT/DELETE schlagen diese Operationen fehl.

📚 Belege

⚡ Tipp 4★★ FortgeschrittenFOR EACH ROWSTATEMENTPerformance

FOR EACH ROW oder FOR EACH STATEMENT?

😖 Problem
Ein Preis-Update für die ganze Kategorie „Büro“ ändert drei Zeilen. Wie oft feuert der Trigger – dreimal oder einmal? Bei Massen-Updates mit 100 000 Zeilen ist das der Unterschied zwischen Millisekunden und Minuten.
💡 Lösung
Ein Zeilentrigger feuert je betroffener Zeile, ein Anweisungstrigger einmal pro Anweisung – auch wenn keine Zeile betroffen ist. SQLite kennt nur Zeilentrigger (hier: drei Log-Zeilen), SQL Server nur Anweisungstrigger; PostgreSQL und Oracle beides. Führe die PostgreSQL-Fassung aus: 3 × ROW + 1 × STATEMENT.

🧪 Ausprobieren – Vorher / Nachher

jede Ausführung startet mit frischen Beispieldaten
SQL-Engine wird geladen …

🌍 Dialekte nebeneinander

Ausgeführt werden nur SQLite (sql.js) und – wo markiert – PostgreSQL (PGlite). MySQL/MariaDB, SQL Server und Oracle sind nach der offiziellen Dokumentation geschrieben, laufen hier aber nicht.

PostgreSQLab 10 (Transition Tables)
▶ ausführbar · PGlite
CREATE TRIGGER artikel_anweisung AFTER UPDATE ON artikel
REFERENCING NEW TABLE AS neu OLD TABLE AS alt
FOR EACH STATEMENT EXECUTE FUNCTION log_anweisung();

Transition Tables machen Anweisungstrigger mengenbasiert – nur für AFTER-Trigger.

MySQL / MariaDBfehlt – Ersatzweg
nur gezeigt, nicht ausgeführt
-- nur FOR EACH ROW
CREATE TRIGGER artikel_zeile AFTER UPDATE ON artikel
FOR EACH ROW
INSERT INTO trigger_log (trigger_name, info)
VALUES ('artikel_zeile', CONCAT(OLD.artikel_nr, ': ', OLD.preis, ' → ', NEW.preis));

Mehrere Trigger pro Ereignis mit FOLLOWS/PRECEDES ordnen (ab 5.7.2).

SQL Server
nur gezeigt, nicht ausgeführt
-- nur anweisungsweise: einmal pro UPDATE, inserted hat n Zeilen
CREATE OR ALTER TRIGGER artikel_anweisung ON artikel AFTER UPDATE
AS
  INSERT INTO trigger_log (trigger_name, info)
  SELECT 'artikel_anweisung', CONCAT(i.artikel_nr, ': ', d.preis, ' → ', i.preis)
    FROM inserted i JOIN deleted d ON d.artikel_nr = i.artikel_nr;

Feuert auch bei 0 betroffenen Zeilen. Wer „pro Zeile“ denkt und SELECT @x = … FROM inserted schreibt, verarbeitet nur eine zufällige Zeile.

Oracle
nur gezeigt, nicht ausgeführt
-- Zeilentrigger
CREATE OR REPLACE TRIGGER artikel_zeile AFTER UPDATE ON artikel FOR EACH ROW
BEGIN
  INSERT INTO trigger_log (trigger_name, info)
  VALUES ('artikel_zeile', :OLD.artikel_nr || ': ' || :OLD.preis || ' → ' || :NEW.preis);
END;
/
-- Anweisungstrigger: ohne FOR EACH ROW (kein :NEW/:OLD)

Compound Trigger (ab 11g) bündeln BEFORE STATEMENT, BEFORE/AFTER EACH ROW und AFTER STATEMENT mit gemeinsamem Zustand.

SQLite
▶ ausgeführt · sql.js
CREATE TRIGGER artikel_zeile AFTER UPDATE ON artikel
FOR EACH ROW
BEGIN
  INSERT INTO trigger_log (trigger_name, info) VALUES ('artikel_zeile', old.artikel_nr);
END;

Nur FOR EACH ROW – ein FOR EACH STATEMENT gibt es nicht.

⚠️ Fallstricke

  • Zeilentrigger bei Massen-Updates: n Aufrufe, n kleine INSERTs ins Log. Wenn möglich mengenbasiert (Anweisungstrigger mit Transition Tables) arbeiten.
  • Anweisungstrigger feuern auch bei 0 Zeilen – Logik muss damit umgehen (in SQL Server z. B. IF NOT EXISTS (SELECT 1 FROM inserted) RETURN;).
  • Trigger sind unsichtbar: Wer UPDATE artikel liest, sieht nicht, was sonst noch passiert. Namenskonvention, Dokumentation und ein Blick in den Katalog (sqlite_master, pg_trigger, sys.triggers) gehören dazu.

📚 Belege

⚡ Tipp 5★★★ ProfiRekursionrecursive_triggersKaskade

Kaskaden und Rekursion: Trigger lösen Trigger aus

😖 Problem
Wechselt eine Teamleitung die Abteilung, soll das ganze Team folgen – auch die Mitarbeitenden eine Ebene tiefer. Ein Trigger, der dieselbe Tabelle ändert, müsste sich selbst erneut auslösen.
💡 Lösung
In SQLite sind rekursive Trigger standardmäßig aus: Das innere UPDATE löst den Trigger dann nicht noch einmal aus, und nur die direkten Untergebenen (6 und 8) wechseln. Mit PRAGMA recursive_triggers = ON wandert die Änderung bis Greta (7) hinunter. Probiere beide Varianten – und nutze den Ablauf-Debugger darunter.

🧪 Ausprobieren – Vorher / Nachher

jede Ausführung startet mit frischen Beispieldaten
SQL-Engine wird geladen …

SQL-Engine wird geladen …

🌍 Dialekte nebeneinander

Ausgeführt werden nur SQLite (sql.js) und – wo markiert – PostgreSQL (PGlite). MySQL/MariaDB, SQL Server und Oracle sind nach der offiziellen Dokumentation geschrieben, laufen hier aber nicht.

PostgreSQL
▶ ausführbar · PGlite
-- Trigger lösen sich immer rekursiv aus; Tiefe begrenzen:
CREATE TRIGGER abteilung_vererben AFTER UPDATE OF abteilung ON mitarbeiter
FOR EACH ROW WHEN (pg_trigger_depth() < 10)
EXECUTE FUNCTION abteilung_vererben();

pg_trigger_depth() liefert die aktuelle Verschachtelungstiefe.

MySQL / MariaDBfehlt – Ersatzweg
nur gezeigt, nicht ausgeführt
-- Fehler 1442: Ein Trigger darf die Tabelle, die ihn ausgelöst hat,
-- nicht ändern. Lösung: rekursive CTE in einer Prozedur
UPDATE mitarbeiter m
  JOIN (WITH RECURSIVE team AS (
          SELECT ma_id FROM mitarbeiter WHERE ma_id = 3
          UNION ALL
          SELECT m2.ma_id FROM mitarbeiter m2 JOIN team t ON m2.chef_id = t.ma_id)
        SELECT ma_id FROM team) t ON t.ma_id = m.ma_id
   SET m.abteilung = 'Produkt';
SQL Server
nur gezeigt, nicht ausgeführt
ALTER DATABASE CURRENT SET RECURSIVE_TRIGGERS ON;   -- Standard: OFF
-- verschachtelte Trigger (A → B → A) max. 32 Ebenen;
-- im Trigger: IF TRIGGER_NESTLEVEL() > 10 RETURN;

Direkte Rekursion (Trigger ändert eigene Tabelle) nur mit RECURSIVE_TRIGGERS ON; indirekte über die Serveroption „nested triggers“.

Oraclefehlt – Ersatzweg
nur gezeigt, nicht ausgeführt
-- Zeilentrigger darf mitarbeiter nicht erneut ändern (ORA-04091).
-- Lösung: hierarchische Abfrage im UPDATE
UPDATE mitarbeiter SET abteilung = 'Produkt'
 WHERE ma_id IN (SELECT ma_id FROM mitarbeiter
                  START WITH ma_id = 3
                  CONNECT BY PRIOR ma_id = chef_id);
SQLite
▶ ausgeführt · sql.js
PRAGMA recursive_triggers = ON;   -- Standard: OFF
-- Grenze: SQLITE_MAX_TRIGGER_DEPTH (Standard 1000)

Das Pragma gilt pro Verbindung und muss vor der Ausführung gesetzt werden.

⚠️ Fallstricke

  • Ohne Abbruchbedingung (abteilung <> new.abteilung) läuft eine Rekursion bis zur Tiefengrenze und bricht dann mit Fehler ab.
  • Kaskaden sind schwer zu debuggen: Eine harmlose Anweisung löst eine Kette aus, die niemand im Anwendungscode sieht. Häufig ist eine explizite Anweisung (rekursive CTE, siehe Kapitel Abfragemuster) klarer.
  • Rekursive Trigger sind dialektabhängig: SQLite und SQL Server standardmäßig aus, PostgreSQL immer an, MySQL und Oracle verbieten die Änderung der eigenen Tabelle im Zeilentrigger.

📚 Belege

Grundlagen in der Schwester-App
↗ VisualSql: rekursive CTE Runde für Runde