Datenmodellierung

Datenbanken
SQL
R
Python
Relationales Modell, Schlüssel, Beziehungen und Normalisierung.

Kernideen

  • Redundanz erzeugt Anomalien: Was mehrfach gespeichert ist, wird irgendwann an einer Stelle geändert und widerspricht sich dann.
  • Der Primärschlüssel identifiziert eine Zeile, der Fremdschlüssel verweist auf eine Zeile einer anderen Tabelle.
  • Referentielle Integrität verhindert Verweise ins Leere. In SQLite ist sie standardmässig abgeschaltet.
  • Die Kardinalität entscheidet über die Umsetzung: eins zu viele über einen Fremdschlüssel, viele zu viele über eine eigene Verbindungstabelle.
  • Normalformen sind keine Theorie um ihrer selbst willen, sondern Regeln gegen genau diese Anomalien.
  • Für Auswertungen wird bewusst wieder zusammengeführt; das Sternschema ist absichtlich nicht vollständig normalisiert.

Erklärung

Vorwissen: keines. Hilfreich ist Tidy Data, denn die Regel “eine Tabelle je Beobachtungseinheit” ist derselbe Gedanke in der Sprache der Datenaufbereitung. Wie man die entworfenen Tabellen abfragt, steht unter SQL, der Zugriff aus R und Python unter Datenbankzugriff.

Die Begriffe

Begriff Bedeutung im Beispiel
Relation Tabelle mit festen Spalten artikel
Tupel eine Zeile ein Artikel
Attribut eine Spalte bezeichnung
Primärschlüssel identifiziert eine Zeile eindeutig artikel_id
Fremdschlüssel verweist auf einen Primärschlüssel bestellung.artikel_id
Zusammengesetzter Schlüssel mehrere Spalten zusammen (artikel_id, lieferant_id)

Die drei Anomalien

Anomalie passiert beim Beispiel
Änderungsanomalie Umbenennen Der Artikel wird in einer Zeile umbenannt, in den anderen nicht.
Einfügeanomalie Erfassen Ein neuer Artikel ohne Bestellung lässt sich gar nicht anlegen.
Löschanomalie Löschen Mit der letzten Bestellung verschwindet auch der Artikel.

Die Normalformen

Form Regel Verstoss im Beispiel
1. NF Jede Zelle enthält einen Wert mengen enthält 10/25
2. NF Jedes Nichtschlüsselattribut hängt vom ganzen Schlüssel ab bei zusammengesetztem Schlüssel: Artikelname hängt nur an der Artikelnummer
3. NF Kein Nichtschlüsselattribut hängt von einem anderen ab kategorie_leiter hängt an kategorie, nicht an der Bestellung

Die Faustregel dahinter lautet: Jede Tatsache an genau einer Stelle. Wer sie einhält, bekommt die drei Anomalien nicht.

Wann man bewusst denormalisiert

Normalisierung ist auf Schreiben optimiert. Für Auswertungen dreht sich das um: Ein Bericht, der zehn Tabellen verbinden muss, ist langsam und umständlich. Deshalb legt man für Analysen bewusst breitere Tabellen an (Sternschema, aufbereitete Auswertungstabellen). Der Unterschied ist die Absicht: Redundanz aus Versehen erzeugt Widersprüche, Redundanz mit Absicht wird aus der normalisierten Quelle neu erzeugt und nie von Hand geändert.

Kurz nachgeschlagen

Aufgabe SQL
Primärschlüssel artikel_id INTEGER PRIMARY KEY
Eindeutigkeit ohne Schlüssel bezeichnung TEXT NOT NULL UNIQUE
Fremdschlüssel artikel_id INTEGER NOT NULL REFERENCES artikel(artikel_id)
in SQLite einschalten PRAGMA foreign_keys = ON
zusammengesetzter Schlüssel PRIMARY KEY (artikel_id, lieferant_id)
verwaiste Zeilen finden LEFT JOIN ... WHERE a.id IS NULL
Widersprüche finden GROUP BY schluessel HAVING COUNT(DISTINCT wert) > 1

Beispiele

Frage und Datenlage

Eine Tabelle flach enthält sechs Bestellungen mit Artikelname und Kategorie in jeder Zeile, insgesamt vier verschiedene Artikel. Was passiert bei einer unvollständigen Umbenennung, und was beim Löschen der letzten Bestellung eines Artikels?

Rechnung

frage("SELECT COUNT(*) AS zeilen, COUNT(DISTINCT artikel) AS artikel FROM flach")
zeilen artikel
6 4
tue("UPDATE flach SET artikel = 'Ventil 3/8 neu' WHERE bestell_id = 1")
frage("SELECT artikel, COUNT(*) AS n FROM flach GROUP BY artikel ORDER BY artikel")
artikel n
Dichtring 1
Kugellager 608 2
Ventil 3/8 1
Ventil 3/8 neu 1
Zylinder 40 1
tue("DELETE FROM flach WHERE artikel = 'Dichtring'")
frage("SELECT COUNT(*) AS zeilen, COUNT(DISTINCT artikel) AS artikel FROM flach")
zeilen artikel
5 4
print(frage("SELECT COUNT(*) AS zeilen, COUNT(DISTINCT artikel) AS artikel FROM flach"
            ).to_string(index=False))
 zeilen  artikel
      6        4
_ = verbindung.execute("UPDATE flach SET artikel = 'Ventil 3/8 neu' WHERE bestell_id = 1")
verbindung.commit()
print(frage("SELECT artikel, COUNT(*) AS n FROM flach GROUP BY artikel ORDER BY artikel"
            ).to_string(index=False))
       artikel  n
     Dichtring  1
Kugellager 608  2
    Ventil 3/8  1
Ventil 3/8 neu  1
   Zylinder 40  1
_ = verbindung.execute("DELETE FROM flach WHERE artikel = 'Dichtring'")
verbindung.commit()
print(frage("SELECT COUNT(*) AS zeilen, COUNT(DISTINCT artikel) AS artikel FROM flach"
            ).to_string(index=False))
 zeilen  artikel
      5        4

Output Zeile für Zeile

Schritt Ergebnis Anomalie
Ausgangslage 6 Zeilen, 4 Artikel Der Name steht bei jeder Bestellung erneut.
nach der Umbenennung einer Zeile 5 verschiedene Artikel: Ventil 3/8 und Ventil 3/8 neu nebeneinander Änderungsanomalie. Ein Artikel ist jetzt zwei, und keine Zeile sieht falsch aus.
nach dem Löschen der Dichtring-Bestellung 5 Zeilen, 4 Artikel Löschanomalie. Der Artikel Dichtring existiert nicht mehr, obwohl nur eine Bestellung gelöscht wurde.
ein neuer Artikel ohne Bestellung gar nicht erfassbar Einfügeanomalie. Es gäbe keine Zeile, in die er passt, ausser einer mit leeren Bestelldaten.

Interpretation und Ergebnissatz

Alle drei Anomalien haben dieselbe Ursache: Zwei verschiedene Dinge, der Artikel und die Bestellung, stehen in derselben Zeile. Die Aufteilung auf zwei Tabellen behebt alle drei auf einmal, und dieselbe Überlegung steht unter Tidy Data als Regel “eine Tabelle je Beobachtungseinheit”.

Die unvollständige Umbenennung macht aus vier Artikeln fünf, und das Löschen der einzigen Dichtring-Bestellung löscht den Artikel gleich mit. Ein Artikel ohne Bestellung liesse sich in dieser Tabelle gar nicht erfassen.

Frage und Datenlage

Die Tabelle bestellung verweist mit REFERENCES auf artikel. Wird eine Bestellung mit der nicht existierenden Artikelnummer 999 abgelehnt?

Rechnung

frage("PRAGMA foreign_keys")           # 0 heisst: Pruefung ist aus
foreign_keys
0
ohne <- try(dbExecute(con,
  "INSERT INTO bestellung VALUES (98, 999, '2026-04-01', 10.0)"), silent = TRUE)
if (inherits(ohne, "try-error")) "abgelehnt" else "durchgelassen"
[1] "durchgelassen"
frage("SELECT COUNT(*) AS verwaist FROM bestellung b
       LEFT JOIN artikel a ON a.artikel_id = b.artikel_id
       WHERE a.artikel_id IS NULL")
verwaist
1
tue("DELETE FROM bestellung WHERE bestell_id = 98")
tue("PRAGMA foreign_keys = ON")

mit <- try(dbExecute(con,
  "INSERT INTO bestellung VALUES (99, 999, '2026-04-01', 10.0)"), silent = TRUE)
if (inherits(mit, "try-error")) "abgelehnt" else "durchgelassen"
[1] "abgelehnt"
print(frage("PRAGMA foreign_keys").to_string(index=False))
 foreign_keys
            0
try:
    _ = verbindung.execute("INSERT INTO bestellung VALUES (98, 999, '2026-04-01', 10.0)")
    verbindung.commit()
    print("durchgelassen")
except sqlite3.IntegrityError as fehler:
    print("abgelehnt:", fehler)
durchgelassen
print(frage("""SELECT COUNT(*) AS verwaist FROM bestellung b
               LEFT JOIN artikel a ON a.artikel_id = b.artikel_id
               WHERE a.artikel_id IS NULL""").to_string(index=False))
 verwaist
        1
_ = verbindung.execute("DELETE FROM bestellung WHERE bestell_id = 98")
verbindung.commit()          # PRAGMA wirkt nicht in einer offenen Transaktion
_ = verbindung.execute("PRAGMA foreign_keys = ON")

try:
    _ = verbindung.execute("INSERT INTO bestellung VALUES (99, 999, '2026-04-01', 10.0)")
    print("durchgelassen")
except sqlite3.IntegrityError as fehler:
    print("abgelehnt:", fehler)
abgelehnt: FOREIGN KEY constraint failed

Output Zeile für Zeile

Schritt Ergebnis Erklärung
PRAGMA foreign_keys 0 SQLite prüft Fremdschlüssel aus Gründen der Abwärtskompatibilität standardmässig nicht, obwohl REFERENCES in der Tabellendefinition steht.
Einfügen ohne Prüfung durchgelassen Die Bestellung verweist auf den Artikel 999, den es nicht gibt.
verwaiste Bestellungen 1 Genau diese Zeile. Bei einem INNER JOIN verschwindet sie später stillschweigend, und die Umsatzsumme ist zu klein.
nach PRAGMA foreign_keys = ON abgelehnt, FOREIGN KEY constraint failed Jetzt wirkt die Regel.

Interpretation und Ergebnissatz

Eine Regel, die nicht eingeschaltet ist, ist Dokumentation, keine Garantie. In SQLite gehört PRAGMA foreign_keys = ON an den Anfang jeder Verbindung, denn die Einstellung gilt nicht für die Datei, sondern für die Sitzung. PostgreSQL und MySQL mit InnoDB prüfen von sich aus, siehe PostgreSQL. Und unabhängig davon lohnt die Abfrage nach verwaisten Zeilen bei jedem Datenbestand, der aus Dateien importiert wurde: Dort gibt es gar keine Prüfung.

Ohne eingeschaltete Prüfung nimmt SQLite die Bestellung mit der nicht existierenden Artikelnummer 999 klaglos an; die Datenbank enthält danach eine verwaiste Zeile. Erst mit PRAGMA foreign_keys = ON wird derselbe Versuch abgelehnt.

Frage und Datenlage

Was passiert bei einer doppelten Artikelnummer, bei einer doppelten Bezeichnung (die Spalte ist UNIQUE), und wie verhält sich UNIQUE, wenn der Wert fehlt?

Rechnung

pk <- try(dbExecute(con, "INSERT INTO artikel VALUES (1,'Doppelt','Test')"),
          silent = TRUE)
if (inherits(pk, "try-error")) "Primaerschluessel abgelehnt" else "durchgelassen"
[1] "Primaerschluessel abgelehnt"
uq <- try(dbExecute(con, "INSERT INTO artikel VALUES (10,'Ventil 3/8','Pneumatik')"),
          silent = TRUE)
if (inherits(uq, "try-error")) "UNIQUE abgelehnt" else "durchgelassen"
[1] "UNIQUE abgelehnt"
tue("CREATE TABLE person (id INTEGER PRIMARY KEY, ahv TEXT UNIQUE)")
tue("INSERT INTO person VALUES (1, NULL), (2, NULL), (3, '756.1')")
frage("SELECT COUNT(*) AS zeilen, COUNT(ahv) AS mit_ahv FROM person")
zeilen mit_ahv
3 1
for sql, was in [("INSERT INTO artikel VALUES (1,'Doppelt','Test')", "Primaerschluessel"),
                 ("INSERT INTO artikel VALUES (10,'Ventil 3/8','Pneumatik')", "UNIQUE")]:
    try:
        _ = verbindung.execute(sql)
        print(was, "durchgelassen")
    except sqlite3.IntegrityError as fehler:
        print(was, "abgelehnt:", fehler)
Primaerschluessel abgelehnt: UNIQUE constraint failed: artikel.artikel_id
UNIQUE abgelehnt: UNIQUE constraint failed: artikel.bezeichnung
_ = verbindung.executescript("""
  CREATE TABLE person (id INTEGER PRIMARY KEY, ahv TEXT UNIQUE);
  INSERT INTO person VALUES (1, NULL), (2, NULL), (3, '756.1');""")
verbindung.commit()
print(frage("SELECT COUNT(*) AS zeilen, COUNT(ahv) AS mit_ahv FROM person"
            ).to_string(index=False))
 zeilen  mit_ahv
      3        1

Output Zeile für Zeile

Versuch Ergebnis Erklärung
Artikelnummer 1 erneut abgelehnt, UNIQUE constraint failed: artikel.artikel_id Der Primärschlüssel ist eindeutig, das prüft die Datenbank immer.
Bezeichnung Ventil 3/8 erneut abgelehnt, UNIQUE constraint failed: artikel.bezeichnung Eine zweite Eindeutigkeit neben dem Schlüssel; sinnvoll für fachliche Kennungen.
zwei Zeilen mit ahv = NULL beide angenommen: 3 Zeilen, davon 1 mit Wert UNIQUE verbietet doppelte Werte. Zwei fehlende Werte gelten nicht als gleich, denn NULL = NULL ist unbekannt.

Interpretation und Ergebnissatz

Die letzte Zeile ist die praktische Falle: Eine UNIQUE-Spalte, die NULL zulässt, verhindert Doppelerfassungen genau dann nicht, wenn die Kennung fehlt. Wer wirklich “jeder Datensatz hat genau eine Kennung, und zwar eine eigene” meint, braucht NOT NULL UNIQUE. Dasselbe Verhalten von NULL steckt hinter mehreren Überraschungen unter SQL, Beispiel 4.

Doppelte Artikelnummern und doppelte Bezeichnungen lehnt die Datenbank ab. Zwei Zeilen ohne AHV-Nummer nimmt dieselbe UNIQUE-Spalte dagegen an, weil zwei fehlende Werte nicht als gleich gelten.

Frage und Datenlage

Artikel werden von mehreren Lieferanten geführt, und ein Lieferant führt mehrere Artikel. Wie sieht die Umsetzung aus, und was verhindert den doppelten Eintrag desselben Paars?

Rechnung

tue("CREATE TABLE lieferant (lieferant_id INTEGER PRIMARY KEY, name TEXT NOT NULL)")
tue("CREATE TABLE artikel_lieferant (
       artikel_id   INTEGER NOT NULL REFERENCES artikel(artikel_id),
       lieferant_id INTEGER NOT NULL REFERENCES lieferant(lieferant_id),
       preis        REAL NOT NULL,
       PRIMARY KEY (artikel_id, lieferant_id))")
tue("INSERT INTO lieferant VALUES (1,'Alpha AG'), (2,'Beta GmbH')")
tue("INSERT INTO artikel_lieferant VALUES (1,1,22.50), (1,2,21.90),
      (2,1,120.00), (3,2,3.40)")

frage("SELECT a.bezeichnung, COUNT(*) AS lieferanten, MIN(al.preis) AS bester
       FROM artikel_lieferant al JOIN artikel a ON a.artikel_id = al.artikel_id
       GROUP BY a.artikel_id ORDER BY a.bezeichnung")
bezeichnung lieferanten bester
Kugellager 608 1 3.4
Ventil 3/8 2 21.9
Zylinder 40 1 120.0
doppelt <- try(dbExecute(con, "INSERT INTO artikel_lieferant VALUES (1,1,19.90)"),
               silent = TRUE)
if (inherits(doppelt, "try-error")) "Paar abgelehnt" else "durchgelassen"
[1] "Paar abgelehnt"
_ = verbindung.executescript("""
CREATE TABLE lieferant (lieferant_id INTEGER PRIMARY KEY, name TEXT NOT NULL);
CREATE TABLE artikel_lieferant (
  artikel_id INTEGER NOT NULL REFERENCES artikel(artikel_id),
  lieferant_id INTEGER NOT NULL REFERENCES lieferant(lieferant_id),
  preis REAL NOT NULL,
  PRIMARY KEY (artikel_id, lieferant_id));
INSERT INTO lieferant VALUES (1,'Alpha AG'), (2,'Beta GmbH');
INSERT INTO artikel_lieferant VALUES (1,1,22.50), (1,2,21.90),
  (2,1,120.00), (3,2,3.40);
""")
verbindung.commit()

print(frage("""SELECT a.bezeichnung, COUNT(*) AS lieferanten, MIN(al.preis) AS bester
               FROM artikel_lieferant al
               JOIN artikel a ON a.artikel_id = al.artikel_id
               GROUP BY a.artikel_id ORDER BY a.bezeichnung""").to_string(index=False))
   bezeichnung  lieferanten  bester
Kugellager 608            1     3.4
    Ventil 3/8            2    21.9
   Zylinder 40            1   120.0
try:
    _ = verbindung.execute("INSERT INTO artikel_lieferant VALUES (1,1,19.90)")
    print("durchgelassen")
except sqlite3.IntegrityError as fehler:
    print("abgelehnt:", fehler)
abgelehnt: UNIQUE constraint failed: artikel_lieferant.artikel_id, artikel_lieferant.lieferant_id

Output Zeile für Zeile

Artikel Lieferanten bester Preis Erklärung
Kugellager 608 1 3.40
Ventil 3/8 2 21.90 Zwei Lieferanten führen denselben Artikel; der Preis gehört deshalb zur Beziehung, nicht zum Artikel.
Zylinder 40 1 120.00
dasselbe Paar erneut abgelehnt Der zusammengesetzte Primärschlüssel (artikel_id, lieferant_id) lässt jedes Paar genau einmal zu.

Der Preis steht in der Verbindungstabelle, weil er weder allein zum Artikel noch allein zum Lieferanten gehört. Das ist die übliche Prüffrage bei einer Verbindungstabelle: Welche Angaben hängen vom Paar ab?

Interpretation und Ergebnissatz

Eine viele-zu-viele-Beziehung lässt sich nicht mit einem Fremdschlüssel in einer der beiden Tabellen abbilden; jeder Versuch endet in wiederholten Spalten wie lieferant_1, lieferant_2. Die Verbindungstabelle ist die Lösung, und ihr zusammengesetzter Schlüssel ist zugleich die Regel gegen Doppelerfassungen.

Das Ventil wird von zwei Lieferanten geführt, der günstigere Preis liegt bei 21.90. Dasselbe Paar aus Artikel und Lieferant ein zweites Mal einzutragen, lehnt der zusammengesetzte Primärschlüssel ab.

Frage und Datenlage

Eine Tabelle führt je Bestellung den Artikel, die Kategorie, den Leiter der Kategorie und die Mengen als 10/25 in einer Zelle. Welche Normalform ist verletzt, und was folgt daraus?

Rechnung

tue("CREATE TABLE unnormal (bestell_id INTEGER, artikel TEXT, kategorie TEXT,
                            kategorie_leiter TEXT, mengen TEXT)")
tue("INSERT INTO unnormal VALUES
  (1,'Ventil 3/8','Pneumatik','Meier','10/25'),
  (2,'Zylinder 40','Pneumatik','Meier','4'),
  (3,'Kugellager 608','Mechanik','Suter','100/60')")

frage("SELECT * FROM unnormal")
bestell_id artikel kategorie kategorie_leiter mengen
1 Ventil 3/8 Pneumatik Meier 10/25
2 Zylinder 40 Pneumatik Meier 4
3 Kugellager 608 Mechanik Suter 100/60
frage("SELECT kategorie, COUNT(DISTINCT kategorie_leiter) AS leiter
       FROM unnormal GROUP BY kategorie")
kategorie leiter
Mechanik 1
Pneumatik 1
tue("UPDATE unnormal SET kategorie_leiter = 'Meyer' WHERE bestell_id = 1")
frage("SELECT kategorie, COUNT(DISTINCT kategorie_leiter) AS leiter
       FROM unnormal GROUP BY kategorie")
kategorie leiter
Mechanik 1
Pneumatik 2
_ = verbindung.executescript("""
CREATE TABLE unnormal (bestell_id INTEGER, artikel TEXT, kategorie TEXT,
                       kategorie_leiter TEXT, mengen TEXT);
INSERT INTO unnormal VALUES
 (1,'Ventil 3/8','Pneumatik','Meier','10/25'),
 (2,'Zylinder 40','Pneumatik','Meier','4'),
 (3,'Kugellager 608','Mechanik','Suter','100/60');
""")
verbindung.commit()

print(frage("SELECT * FROM unnormal").to_string(index=False))
 bestell_id        artikel kategorie kategorie_leiter mengen
          1     Ventil 3/8 Pneumatik            Meier  10/25
          2    Zylinder 40 Pneumatik            Meier      4
          3 Kugellager 608  Mechanik            Suter 100/60
print(frage("""SELECT kategorie, COUNT(DISTINCT kategorie_leiter) AS leiter
               FROM unnormal GROUP BY kategorie""").to_string(index=False))
kategorie  leiter
 Mechanik       1
Pneumatik       1
_ = verbindung.execute("UPDATE unnormal SET kategorie_leiter = 'Meyer' WHERE bestell_id = 1")
verbindung.commit()
print(frage("""SELECT kategorie, COUNT(DISTINCT kategorie_leiter) AS leiter
               FROM unnormal GROUP BY kategorie""").to_string(index=False))
kategorie  leiter
 Mechanik       1
Pneumatik       2

Output Zeile für Zeile

Befund Ergebnis verletzte Regel
Spalte mengen enthält 10/25 zwei Werte in einer Zelle 1. Normalform. Weder summieren noch zählen ist möglich, siehe Tidy Data, Beispiel 2.
kategorie_leiter je Kategorie vorher 1 je Kategorie Noch ist alles widerspruchsfrei.
nach der Änderung einer Zeile Pneumatik hat 2 Leiter 3. Normalform. Der Leiter hängt an der Kategorie, nicht an der Bestellung, steht aber in jeder Bestellzeile.

Die Abfrage COUNT(DISTINCT ...) je Gruppe ist der Standardgriff, um solche Widersprüche zu finden, und zwar unabhängig davon, ob die Daten aus einer Datenbank oder aus einer CSV-Datei kommen.

Interpretation und Ergebnissatz

Die dritte Normalform klingt abstrakt und beschreibt genau diesen Fall: Eine Angabe, die zu einer anderen Spalte gehört, wird in jeder Zeile wiederholt und irgendwann an einer Stelle geändert. Die Abhilfe ist eine eigene Tabelle kategorie mit dem Leiter, auf die verwiesen wird. Danach gibt es den Leiter genau einmal, und die Frage, welcher Eintrag gilt, stellt sich nicht mehr.

Die Spalte mengen mit 10/25 verletzt die erste Normalform. Die Änderung des Kategorieleiters in einer einzigen Zeile führt dazu, dass Pneumatik zwei Leiter hat, und verletzt damit die dritte.

Verständnisfragen

Warum ist eine grosse Tabelle mit Artikelname in jeder Bestellzeile problematisch?

Dieselbe Angabe steht mehrfach und wird irgendwann nur teilweise geändert
Richtig. In Beispiel 1 werden aus vier Artikeln fünf, ohne dass eine Zeile falsch aussieht. Dazu kommen Einfüge- und Löschanomalie.
Sie braucht mehr Speicherplatz
Das stimmt, ist aber selten das Problem.
Abfragen darauf sind langsamer
Für Auswertungen ist die breite Tabelle oft sogar schneller; deshalb denormalisiert man dafür bewusst.

Eine SQLite-Datenbank hat REFERENCES in allen Tabellendefinitionen, und trotzdem gibt es Bestellungen zu nicht existierenden Artikeln. Wie kommt das?

SQLite prüft Fremdschlüssel nur, wenn PRAGMA foreign_keys = ON gesetzt ist, und zwar je Verbindung
Richtig, wie Beispiel 2 zeigt. Ohne die Einstellung ist REFERENCES reine Dokumentation.
Die Verweise wurden nachträglich gelöscht
Möglich, aber dann hätte die eingeschaltete Prüfung auch das Löschen verhindert.
REFERENCES gilt nur für Text-Spalten
Der Typ spielt keine Rolle.

Eine Spalte ahv ist UNIQUE, trotzdem stehen zwei Zeilen ohne AHV-Nummer darin. Ist das ein Fehler der Datenbank?

Ja, UNIQUE müsste das verhindern
UNIQUE verbietet doppelte Werte, und NULL ist kein Wert.
Nein, zwei fehlende Werte gelten nicht als gleich
Richtig. Wer jede Zeile zu einer eigenen Kennung zwingen will, schreibt NOT NULL UNIQUE.
Nein, weil UNIQUE nur zusammen mit dem Primärschlüssel wirkt
Es wirkt eigenständig, nur eben nicht auf fehlende Werte.

Artikel und Lieferanten stehen in einer viele-zu-viele-Beziehung, und der Einkaufspreis unterscheidet sich je Lieferant. Wo gehört der Preis hin?

In die Verbindungstabelle, denn er hängt vom Paar ab
Richtig. Weder der Artikel noch der Lieferant allein bestimmt ihn. Der zusammengesetzte Schlüssel verhindert zugleich Doppelerfassungen.
In die Artikeltabelle, er gehört zum Artikel
Dann liesse sich nur ein Preis je Artikel speichern.
In die Lieferantentabelle
Dann gäbe es nur einen Preis je Lieferant, unabhängig vom Artikel.

In einer Bestelltabelle steht neben der Kategorie auch deren Leiter. Welche Normalform ist verletzt, und woran merkt man es?

Die dritte; der Leiter hängt an der Kategorie, und eine Änderung an einer Stelle erzeugt zwei Leiter für dieselbe Kategorie
Richtig. Finden lässt sich das mit COUNT(DISTINCT leiter) je Kategorie, wie in Beispiel 5.
Die erste, weil zu viele Spalten in der Tabelle stehen
Die erste betrifft mehrere Werte in einer Zelle, nicht die Anzahl der Spalten.
Keine, solange die Daten stimmen
Sie stimmen nur so lange, bis jemand eine einzelne Zeile ändert.

Verlinkte Ressourcen