SQL

SQL
Datenbanken
R
Python
Abfragen, Joins, Aggregation und Unterabfragen.

Kernideen

  • SQL ist deklarativ: Man beschreibt das Ergebnis, nicht den Weg dorthin.
  • Die Auswertungsreihenfolge weicht von der Schreibreihenfolge ab. Deshalb filtert WHERE Zeilen vor der Gruppierung und HAVING Gruppen danach.
  • Ein INNER JOIN verliert, was keinen Partner hat; ein LEFT JOIN behält es und füllt mit NULL.
  • NULL ist kein Wert, sondern das Fehlen eines Werts. Jeder Vergleich damit ergibt weder wahr noch falsch, und das verändert Ergebnisse still.
  • COUNT(*) zählt Zeilen, COUNT(spalte) zählt vorhandene Werte.
  • Fensterfunktionen rechnen je Gruppe, ohne die Zeilen zusammenzufassen.

Erklärung

Vorwissen: Datenmodellierung für Schlüssel und Beziehungen. Wer Data Wrangling oder pandas kennt, findet dieselben Operationen wieder: WHERE ist filter, GROUP BY ist groupby, JOIN ist merge, und SUM() OVER (PARTITION BY ...) ist transform. Wie man Abfragen aus R oder Python absetzt, steht unter Datenbankzugriff.

Die Daten dieser Seite

Zwei Tabellen in einer SQLite-Datenbank, die beim Bauen der Seite entsteht: sechs Artikel (mit Kategorie und einem Rabatt, der bei drei Artikeln fehlt) und zehn Bestellungen. Ein Artikel, der Schlauch, wurde nie bestellt. Alle Ausgaben sind echte Abfrageergebnisse.

Die Auswertungsreihenfolge

Reihenfolge Klausel was passiert
1 FROM, JOIN Tabellen zusammenstellen
2 WHERE einzelne Zeilen filtern
3 GROUP BY Zeilen zu Gruppen zusammenfassen
4 HAVING Gruppen filtern
5 SELECT Spalten auswählen und berechnen
6 ORDER BY sortieren
7 LIMIT abschneiden

Daraus folgt die häufigste Verwirrung: WHERE SUM(betrag) > 500 ist nicht möglich, weil zum Zeitpunkt von WHERE noch nicht gruppiert wurde. Ebenso lässt sich ein in SELECT vergebener Aliasname in WHERE nicht verwenden, in ORDER BY dagegen schon.

Die Join-Arten

Join behält typischer Einsatz
INNER JOIN nur Paare, die auf beiden Seiten passen Bestellungen mit ihren Artikeln
LEFT JOIN alle Zeilen links, rechts NULL wenn nichts passt alle Artikel, auch nie bestellte
FULL OUTER JOIN alles von beiden Seiten Abgleich zweier Quellen
CROSS JOIN jede Kombination Kalender mal Standorte als Gerüst

NULL: die dritte Wahrheit

Ein Vergleich mit NULL ergibt nicht wahr und nicht falsch, sondern unbekannt, und WHERE behält nur Zeilen, deren Bedingung wahr ist.

Ausdruck Ergebnis Folge
rabatt = NULL unbekannt findet nie etwas; richtig ist IS NULL
rabatt <> 5 unbekannt, wenn rabatt fehlt die Zeilen fehlen im Ergebnis
COUNT(rabatt) zählt nur vorhandene weniger als COUNT(*)
AVG(rabatt) ignoriert fehlende Nenner ist die Anzahl der vorhandenen
NOT IN (Unterabfrage mit NULL) nie wahr liefert gar nichts; NOT EXISTS verwenden
COALESCE(rabatt, 0) Ersatzwert macht die Annahme sichtbar

Kurz nachgeschlagen

Aufgabe SQL
filtern und sortieren WHERE ... ORDER BY ... LIMIT 10
Gruppen bilden und filtern GROUP BY kategorie HAVING COUNT(*) >= 3
verbinden JOIN artikel a ON a.artikel_id = b.artikel_id
fehlende Werte IS NULL, COALESCE(x, 0)
Fallunterscheidung CASE WHEN betrag > 500 THEN 'gross' ELSE 'klein' END
Zwischenergebnis benennen WITH umsatz AS (...) SELECT ... FROM umsatz
Anteil an der Gruppe SUM(x) OVER (PARTITION BY kategorie)
Rangfolge ROW_NUMBER() OVER (ORDER BY betrag DESC)
Duplikate finden GROUP BY schluessel HAVING COUNT(*) > 1

Beispiele

Frage und Datenlage

Welche Artikel gehören zur Kategorie Pneumatik, und wie gross sind die beiden Tabellen?

SELECT bezeichnung, kategorie
FROM artikel
WHERE kategorie = 'Pneumatik'
ORDER BY bezeichnung

Rechnung

frage("SELECT bezeichnung, kategorie FROM artikel
       WHERE kategorie = 'Pneumatik' ORDER BY bezeichnung")
bezeichnung kategorie
Schlauch 8mm Pneumatik
Ventil 3/8 Pneumatik
Zylinder 40 Pneumatik
frage("SELECT COUNT(*) AS artikel,
              (SELECT COUNT(*) FROM bestellung) AS bestellungen
       FROM artikel")
artikel bestellungen
6 10
print(frage("""SELECT bezeichnung, kategorie FROM artikel
               WHERE kategorie = 'Pneumatik' ORDER BY bezeichnung"""
            ).to_string(index=False))
 bezeichnung kategorie
Schlauch 8mm Pneumatik
  Ventil 3/8 Pneumatik
 Zylinder 40 Pneumatik
print(frage("""SELECT COUNT(*) AS artikel,
                      (SELECT COUNT(*) FROM bestellung) AS bestellungen
               FROM artikel""").to_string(index=False))
 artikel  bestellungen
       6            10

Output Zeile für Zeile

Ergebnis Wert Erklärung
Pneumatik-Artikel Schlauch 8mm, Ventil 3/8, Zylinder 40 ORDER BY sortiert nach Text, deshalb steht der Schlauch vorn.
Artikel insgesamt 6
Bestellungen 10 Die Unterabfrage in SELECT liefert genau einen Wert und darf deshalb dort stehen.

R und Python setzen dieselbe Abfrage ab und bekommen dasselbe Ergebnis; die Arbeit macht die Datenbank. Der Unterschied liegt nur in der Verpackung, dbGetQuery gegen pd.read_sql_query.

Interpretation und Ergebnissatz

Die Abfrage beschreibt, was gewünscht ist. Wie die Datenbank es holt (Index, Reihenfolge der Tabellen, Verfahren beim Verbinden), entscheidet sie selbst. Deshalb sieht dieselbe Abfrage auf zehn wie auf zehn Millionen Zeilen gleich aus.

Drei der sechs Artikel gehören zur Kategorie Pneumatik, und zu den sechs Artikeln gibt es zehn Bestellungen.

Frage und Datenlage

Der Schlauch wurde nie bestellt. Wie viele Zeilen liefert ein Join über beide Tabellen, und welcher Join zeigt den nie bestellten Artikel?

SELECT a.bezeichnung, COUNT(b.bestell_id) AS anzahl,
       COALESCE(SUM(b.betrag), 0) AS umsatz
FROM artikel a
LEFT JOIN bestellung b ON a.artikel_id = b.artikel_id
GROUP BY a.artikel_id
ORDER BY umsatz

Rechnung

frage("SELECT COUNT(*) AS inner_join FROM bestellung b
       JOIN artikel a ON a.artikel_id = b.artikel_id")
inner_join
10
frage("SELECT COUNT(*) AS left_join FROM artikel a
       LEFT JOIN bestellung b ON a.artikel_id = b.artikel_id")
left_join
11
frage("SELECT a.bezeichnung, COUNT(b.bestell_id) AS anzahl,
              COALESCE(SUM(b.betrag), 0) AS umsatz
       FROM artikel a
       LEFT JOIN bestellung b ON a.artikel_id = b.artikel_id
       GROUP BY a.artikel_id ORDER BY umsatz")
bezeichnung anzahl umsatz
Schlauch 8mm 0 0
Dichtring 1 180
Kugellager 608 2 560
Zylinder 40 2 768
Ventil 3/8 3 1200
Sensor induktiv 2 2400
print(len(frage("""SELECT * FROM bestellung b
                   JOIN artikel a ON a.artikel_id = b.artikel_id""")))
10
print(len(frage("""SELECT * FROM artikel a
                   LEFT JOIN bestellung b ON a.artikel_id = b.artikel_id""")))
11
print(frage("""SELECT a.bezeichnung, COUNT(b.bestell_id) AS anzahl,
                      COALESCE(SUM(b.betrag), 0) AS umsatz
               FROM artikel a
               LEFT JOIN bestellung b ON a.artikel_id = b.artikel_id
               GROUP BY a.artikel_id ORDER BY umsatz""").to_string(index=False))
    bezeichnung  anzahl  umsatz
   Schlauch 8mm       0     0.0
      Dichtring       1   180.0
 Kugellager 608       2   560.0
    Zylinder 40       2   768.0
     Ventil 3/8       3  1200.0
Sensor induktiv       2  2400.0

Output Zeile für Zeile

Abfrage Zeilen Erklärung
INNER JOIN 10 so viele wie Bestellungen; der Schlauch fehlt.
LEFT JOIN von artikel aus 11 zehn Bestellungen plus eine Zeile für den Schlauch mit NULL.
Artikel Bestellungen Umsatz Erklärung
Schlauch 8mm 0 0.0 COUNT(b.bestell_id) zählt nur vorhandene Werte und ergibt deshalb 0, während COUNT(*) hier 1 ergäbe. COALESCE macht aus dem NULL der Summe eine 0.
Dichtring 1 180.0
Kugellager 608 2 560.0
Zylinder 40 2 768.0
Ventil 3/8 3 1200.0
Sensor induktiv 2 2400.0 der umsatzstärkste Artikel bei nur zwei Bestellungen

Interpretation und Ergebnissatz

Der Unterschied zwischen 10 und 11 Zeilen ist die ganze Geschichte: Ein INNER JOIN beantwortet “was wurde bestellt”, ein LEFT JOIN “wie steht es um jeden Artikel”. Für eine Auswertung über das Sortiment ist der zweite richtig, sonst fehlen genau die Artikel, die am meisten zu erzählen hätten, nämlich die mit null Bestellungen. Der Unterschied zwischen COUNT(*) und COUNT(spalte) ist dabei entscheidend, sonst steht beim Schlauch eine 1.

Der INNER JOIN liefert zehn Zeilen, der LEFT JOIN elf: Der nie bestellte Schlauch erscheint nur im zweiten, mit null Bestellungen und einem Umsatz von 0.

Frage und Datenlage

Umsatz je Kategorie, danach nur Kategorien mit mindestens drei Bestellungen, und schliesslich nur Bestellungen ab Februar.

SELECT a.kategorie, COUNT(*) AS anzahl, SUM(b.betrag) AS umsatz,
       ROUND(AVG(b.betrag), 2) AS mittel
FROM bestellung b
JOIN artikel a ON a.artikel_id = b.artikel_id
GROUP BY a.kategorie
ORDER BY umsatz DESC

Rechnung

frage("SELECT a.kategorie, COUNT(*) AS anzahl, SUM(b.betrag) AS umsatz,
              ROUND(AVG(b.betrag), 2) AS mittel
       FROM bestellung b JOIN artikel a ON a.artikel_id = b.artikel_id
       GROUP BY a.kategorie ORDER BY umsatz DESC")
kategorie anzahl umsatz mittel
Elektronik 2 2400 1200.00
Pneumatik 5 1968 393.60
Mechanik 3 740 246.67
frage("SELECT a.kategorie, COUNT(*) AS anzahl, SUM(b.betrag) AS umsatz
       FROM bestellung b JOIN artikel a ON a.artikel_id = b.artikel_id
       GROUP BY a.kategorie HAVING COUNT(*) >= 3 ORDER BY umsatz DESC")
kategorie anzahl umsatz
Pneumatik 5 1968
Mechanik 3 740
frage("SELECT a.kategorie, SUM(b.betrag) AS umsatz
       FROM bestellung b JOIN artikel a ON a.artikel_id = b.artikel_id
       WHERE b.datum >= '2026-02-01'
       GROUP BY a.kategorie ORDER BY umsatz DESC")
kategorie umsatz
Elektronik 2400
Pneumatik 1216
Mechanik 560
print(frage("""SELECT a.kategorie, COUNT(*) AS anzahl, SUM(b.betrag) AS umsatz,
                      ROUND(AVG(b.betrag), 2) AS mittel
               FROM bestellung b JOIN artikel a ON a.artikel_id = b.artikel_id
               GROUP BY a.kategorie ORDER BY umsatz DESC""").to_string(index=False))
 kategorie  anzahl  umsatz  mittel
Elektronik       2  2400.0 1200.00
 Pneumatik       5  1968.0  393.60
  Mechanik       3   740.0  246.67
print(frage("""SELECT a.kategorie, COUNT(*) AS anzahl, SUM(b.betrag) AS umsatz
               FROM bestellung b JOIN artikel a ON a.artikel_id = b.artikel_id
               GROUP BY a.kategorie HAVING COUNT(*) >= 3
               ORDER BY umsatz DESC""").to_string(index=False))
kategorie  anzahl  umsatz
Pneumatik       5  1968.0
 Mechanik       3   740.0
print(frage("""SELECT a.kategorie, SUM(b.betrag) AS umsatz
               FROM bestellung b JOIN artikel a ON a.artikel_id = b.artikel_id
               WHERE b.datum >= '2026-02-01'
               GROUP BY a.kategorie ORDER BY umsatz DESC""").to_string(index=False))
 kategorie  umsatz
Elektronik  2400.0
 Pneumatik  1216.0
  Mechanik   560.0

Output Zeile für Zeile

Kategorie Bestellungen Umsatz Mittel Erklärung
Elektronik 2 2400.0 1200.00 wenige, aber grosse Bestellungen
Pneumatik 5 1968.0 393.60 viele kleine
Mechanik 3 740.0 246.67
Zusatz Ergebnis Erklärung
HAVING COUNT(*) >= 3 nur Pneumatik (1968.0) und Mechanik (740.0) Elektronik fällt mit zwei Bestellungen heraus, obwohl es der grösste Umsatz ist. HAVING filtert Gruppen.
WHERE datum >= '2026-02-01' Elektronik 2400.0, Pneumatik 1216.0, Mechanik 560.0 WHERE filtert Zeilen vor der Gruppierung: Die Januarbestellungen fehlen in den Summen. Elektronik bleibt unverändert, weil dort beide Bestellungen später liegen.

Das Datum steht hier als Text JJJJ-MM-TT. Dieses Format sortiert und vergleicht sich richtig, weshalb der Textvergleich >= '2026-02-01' funktioniert. Bei jedem anderen Format wäre es falsch, siehe Datum und Zeit.

Interpretation und Ergebnissatz

Beide Filter verkleinern das Ergebnis und meinen Verschiedenes. WHERE sagt “diese Zeilen interessieren mich”, HAVING sagt “diese Gruppen interessieren mich”. Wer eine Bedingung an der falschen Stelle setzt, bekommt ein plausibles, aber anderes Ergebnis: hier einmal 1968.0 und einmal 1216.0 für dieselbe Kategorie.

Elektronik führt mit 2400.0 Umsatz aus nur zwei Bestellungen, Pneumatik folgt mit 1968.0 aus fünf. Mit HAVING COUNT(*) >= 3 fällt Elektronik heraus; mit WHERE ab Februar sinkt Pneumatik auf 1216.0.

Frage und Datenlage

Bei drei der sechs Artikel fehlt der Rabatt. Was zählen COUNT(*) und COUNT(rabatt), was findet WHERE rabatt <> 5.0, und was liefert ein NOT IN mit einem NULL in der Unterabfrage?

Rechnung

frage("SELECT COUNT(*) AS zeilen, COUNT(rabatt) AS mit_rabatt,
              ROUND(AVG(rabatt), 4) AS mittel FROM artikel")
zeilen mit_rabatt mittel
6 3 5.8333
frage("SELECT
         (SELECT COUNT(*) FROM artikel WHERE rabatt = NULL)    AS gleich_null,
         (SELECT COUNT(*) FROM artikel WHERE rabatt IS NULL)   AS ist_null,
         (SELECT COUNT(*) FROM artikel WHERE rabatt <> 5.0)    AS ungleich_5")
gleich_null ist_null ungleich_5
0 3 2
frage("SELECT bezeichnung FROM artikel
       WHERE artikel_id NOT IN (SELECT artikel_id FROM bestellung)")
bezeichnung
Schlauch 8mm
# Dieselbe Frage, aber ein NULL in der Unterabfrage
frage("SELECT COUNT(*) AS treffer FROM artikel
       WHERE artikel_id NOT IN
         (SELECT CASE WHEN artikel_id = 1 THEN NULL ELSE artikel_id END
          FROM bestellung)")
treffer
0
print(frage("""SELECT COUNT(*) AS zeilen, COUNT(rabatt) AS mit_rabatt,
                      ROUND(AVG(rabatt), 4) AS mittel FROM artikel"""
            ).to_string(index=False))
 zeilen  mit_rabatt  mittel
      6           3  5.8333
print(frage("""SELECT
         (SELECT COUNT(*) FROM artikel WHERE rabatt = NULL)  AS gleich_null,
         (SELECT COUNT(*) FROM artikel WHERE rabatt IS NULL) AS ist_null,
         (SELECT COUNT(*) FROM artikel WHERE rabatt <> 5.0)  AS ungleich_5"""
            ).to_string(index=False))
 gleich_null  ist_null  ungleich_5
           0         3           2
print(frage("""SELECT bezeichnung FROM artikel
               WHERE artikel_id NOT IN (SELECT artikel_id FROM bestellung)"""
            ).to_string(index=False))
 bezeichnung
Schlauch 8mm
print(len(frage("""SELECT bezeichnung FROM artikel WHERE artikel_id NOT IN
                   (SELECT CASE WHEN artikel_id = 1 THEN NULL ELSE artikel_id END
                    FROM bestellung)""")))
0

Output Zeile für Zeile

Abfrage Ergebnis Erklärung
COUNT(*) 6 alle Zeilen
COUNT(rabatt) 3 nur die Zeilen mit einem Wert
AVG(rabatt) 5.8333 (5.0 + 2.5 + 10.0) / 3, nicht durch 6. Der Nenner ist die Anzahl der vorhandenen Werte.
WHERE rabatt = NULL 0 Treffer Der Vergleich ist nie wahr, auch nicht für die fehlenden.
WHERE rabatt IS NULL 3 So fragt man nach dem Fehlen.
WHERE rabatt <> 5.0 2 statt 5 Die drei Zeilen ohne Rabatt fehlen: Für sie ist die Bedingung unbekannt, nicht wahr.
NOT IN ohne NULL Schlauch 8mm der nie bestellte Artikel
NOT IN mit einem NULL 0 Zeilen Sobald die Unterabfrage ein NULL enthält, ist NOT IN nie wahr. Das Ergebnis ist leer, ohne Fehlermeldung.

Interpretation und Ergebnissatz

Die Zeile WHERE rabatt <> 5.0 ist der Fall, der in Auswertungen am häufigsten schadet: Sie sieht aus wie “alle ausser den Fünfern” und liefert “alle ausser den Fünfern und ausser allen, bei denen der Rabatt fehlt”. Richtig wäre WHERE rabatt IS NULL OR rabatt <> 5.0. Beim NOT IN ist die Falle noch stiller, weil das Ergebnis nicht falsch, sondern leer ist; NOT EXISTS verhält sich dort wie erwartet.

Von sechs Artikeln haben drei einen Rabatt, ihr Mittel beträgt 5.8333. WHERE rabatt <> 5.0 findet nur zwei Artikel statt fünf, und ein NOT IN mit einem NULL in der Unterabfrage liefert gar keine Zeile.

Frage und Datenlage

Umsatz je Artikel, und wie viel Prozent davon auf die jeweilige Kategorie entfallen. Ohne die Zeilen zusammenzufassen.

WITH je_artikel AS (
  SELECT artikel_id, SUM(betrag) AS summe
  FROM bestellung
  GROUP BY artikel_id
)
SELECT a.bezeichnung, a.kategorie, j.summe,
       ROUND(100.0 * j.summe / SUM(j.summe) OVER (PARTITION BY a.kategorie), 1)
         AS anteil_kat
FROM je_artikel j
JOIN artikel a ON a.artikel_id = j.artikel_id
ORDER BY a.kategorie, j.summe DESC

Rechnung

frage("WITH je_artikel AS (
         SELECT artikel_id, SUM(betrag) AS summe FROM bestellung
         GROUP BY artikel_id)
       SELECT a.bezeichnung, a.kategorie, j.summe,
              ROUND(100.0 * j.summe /
                    SUM(j.summe) OVER (PARTITION BY a.kategorie), 1) AS anteil_kat
       FROM je_artikel j JOIN artikel a ON a.artikel_id = j.artikel_id
       ORDER BY a.kategorie, j.summe DESC")
bezeichnung kategorie summe anteil_kat
Sensor induktiv Elektronik 2400 100.0
Kugellager 608 Mechanik 560 75.7
Dichtring Mechanik 180 24.3
Ventil 3/8 Pneumatik 1200 61.0
Zylinder 40 Pneumatik 768 39.0
print(frage("""WITH je_artikel AS (
                 SELECT artikel_id, SUM(betrag) AS summe FROM bestellung
                 GROUP BY artikel_id)
               SELECT a.bezeichnung, a.kategorie, j.summe,
                      ROUND(100.0 * j.summe /
                            SUM(j.summe) OVER (PARTITION BY a.kategorie), 1)
                        AS anteil_kat
               FROM je_artikel j JOIN artikel a ON a.artikel_id = j.artikel_id
               ORDER BY a.kategorie, j.summe DESC""").to_string(index=False))
    bezeichnung  kategorie  summe  anteil_kat
Sensor induktiv Elektronik 2400.0       100.0
 Kugellager 608   Mechanik  560.0        75.7
      Dichtring   Mechanik  180.0        24.3
     Ventil 3/8  Pneumatik 1200.0        61.0
    Zylinder 40  Pneumatik  768.0        39.0

Output Zeile für Zeile

Artikel Kategorie Umsatz Anteil an der Kategorie
Sensor induktiv Elektronik 2400.0 100.0 Prozent
Kugellager 608 Mechanik 560.0 75.7 Prozent
Dichtring Mechanik 180.0 24.3 Prozent
Ventil 3/8 Pneumatik 1200.0 61.0 Prozent
Zylinder 40 Pneumatik 768.0 39.0 Prozent
Baustein Wirkung
WITH je_artikel AS (...) benennt das Zwischenergebnis, statt es zu verschachteln. Es liest sich von oben nach unten wie eine Pipe.
SUM(j.summe) OVER (PARTITION BY a.kategorie) die Kategoriesumme in jeder Zeile, ohne zu gruppieren
100.0 * erzwingt Gleitkommadivision; mit 100 * würde SQLite ganzzahlig rechnen
fehlender Schlauch Er taucht nicht auf, weil je_artikel nur Artikel mit Bestellungen enthält.

Interpretation und Ergebnissatz

Die Fensterfunktion ist in SQL das, was transform in pandas und mutate(.by = ...) in dplyr sind: ein Gruppenwert, der bei den Einzelzeilen bleibt. Ohne sie bräuchte es eine zweite Abfrage und einen Join auf die Kategoriesummen. Das Ergebnis zeigt zugleich eine Abhängigkeit, die in der Kategorie-Auswertung aus Beispiel 3 unsichtbar war: Der gesamte Elektronikumsatz hängt an einem einzigen Artikel.

Der Sensor macht 100 Prozent des Elektronikumsatzes aus, das Kugellager 75.7 Prozent der Mechanik und das Ventil 61.0 Prozent der Pneumatik. Der nie bestellte Schlauch fehlt, weil das Zwischenergebnis nur Artikel mit Bestellungen enthält.

Verständnisfragen

Warum lässt sich WHERE SUM(betrag) > 500 nicht schreiben?

WHERE wird vor der Gruppierung ausgewertet, es gibt dort noch keine Summen
Richtig. Bedingungen auf Aggregaten gehören in HAVING, das nach der Gruppierung läuft. Beides in einer Abfrage ist möglich und üblich.
SUM ist in einer Bedingung generell nicht erlaubt
In HAVING ist es erlaubt, es hängt allein am Zeitpunkt.
Es fehlt ein GROUP BY
Auch mit GROUP BY bliebe die Zeile falsch, denn WHERE läuft trotzdem vorher.

Eine Auswertung über das Sortiment zeigt fünf statt sechs Artikel. Was ist die wahrscheinlichste Ursache?

Es wurde ein INNER JOIN verwendet, und ein Artikel hat keine Bestellung
Richtig, wie der Schlauch in Beispiel 2. Für Auswertungen über alle Artikel gehört der LEFT JOIN von der Artikeltabelle aus.
Der Artikel wurde gelöscht
Möglich, aber dann fehlte er auch in SELECT * FROM artikel.
GROUP BY verwirft Gruppen mit nur einer Zeile
Das tut es nicht.

SELECT COUNT(*), COUNT(rabatt) FROM artikel liefert 6 und 3. Was bedeutet das?

Bei drei Artikeln fehlt der Rabatt; COUNT(spalte) zählt nur vorhandene Werte
Richtig. Deshalb teilt AVG(rabatt) auch durch 3 und nicht durch 6.
Drei Artikel haben den Rabatt 0
Eine 0 wäre ein Wert und würde mitgezählt.
COUNT(*) zählt doppelt
Es zählt Zeilen, hier sechs.

WHERE rabatt <> 5.0 liefert zwei von sechs Artikeln, obwohl nur einer den Rabatt 5.0 hat. Warum?

Für die drei Artikel ohne Rabatt ist die Bedingung unbekannt, nicht wahr
Richtig. WHERE behält nur Zeilen, deren Bedingung wahr ist. Gemeint war WHERE rabatt IS NULL OR rabatt <> 5.0.
<> ist kein gültiger Operator
Er ist gültig und bedeutet “ungleich”.
Die Datenbank hat einen Index auf rabatt
Ein Index ändert Ergebnisse nicht.

Eine Abfrage mit NOT IN (SELECT kunde_id FROM sperrliste) liefert plötzlich gar keine Zeilen mehr, obwohl es passende Kunden gibt. Was ist zu prüfen?

Ob die Unterabfrage ein NULL enthält
Richtig. Ein einziges NULL macht NOT IN für jede Zeile unbekannt, und das Ergebnis ist leer. NOT EXISTS oder WHERE kunde_id IS NOT NULL in der Unterabfrage lösen es.
Ob die Sperrliste zu gross ist
Die Grösse ändert das Ergebnis nicht.
Ob ein Index fehlt
Das beträfe die Geschwindigkeit, nicht das Ergebnis.

Welche Entsprechung hat SUM(x) OVER (PARTITION BY g) in pandas und dplyr?

groupby("g")["x"].transform("sum") beziehungsweise mutate(.by = g, s = sum(x))
Richtig. Alle drei legen den Gruppenwert in jede Zeile, ohne die Zeilen zusammenzufassen.
groupby("g").agg(...) beziehungsweise summarise()
Die fassen zusammen und liefern eine Zeile je Gruppe.
merge beziehungsweise left_join
Damit lässt sich dasselbe nachbauen, es ist aber der Umweg.

Verlinkte Ressourcen