← Terug naar tutorials

Geavanceerde SQL-queries voor data-analyse: window functions, CTE’s en optimalisatie

geavanceerde sqldata-analysewindow functionsctesql performancequery optimalisatieanalytische functiessql voor data scientists

Geavanceerde SQL-queries voor data-analyse: window functions, CTE’s en optimalisatie

Inleiding

SQL is al lang niet meer alleen een taal om rijen op te halen uit een tabel. In moderne data-analyse gebruik je SQL om complexe berekeningen te doen, trends te detecteren, cohortanalyses te bouwen, data te schonen, en prestaties te optimaliseren zodat dashboards en pipelines betrouwbaar en snel blijven.

In deze tutorial leer je drie pijlers die in de praktijk vaak samenkomen:

  1. Window functions: analytische functies die berekeningen uitvoeren over een “venster” van rijen zonder je resultaatset te reduceren.
  2. CTE’s (Common Table Expressions): leesbare, modulaire query-opbouw, inclusief recursie.
  3. Optimalisatie: indexen, join-strategieën, filter-pushdown, query-plannen, en praktische valkuilen.

De voorbeelden zijn geschreven in generieke SQL met een lichte nadruk op PostgreSQL-syntax waar dat nuttig is. De concepten zijn breed toepasbaar.


Voorbeeldschema en testdata

We gebruiken een klein e-commerce schema: klanten, bestellingen en orderregels. Je kunt dit rechtstreeks uitvoeren in een PostgreSQL-achtige omgeving.

CREATE TABLE klanten (
  klant_id      BIGINT PRIMARY KEY,
  naam          TEXT NOT NULL,
  segment       TEXT NOT NULL,
  aangemaakt_op DATE NOT NULL
);

CREATE TABLE bestellingen (
  bestelling_id BIGINT PRIMARY KEY,
  klant_id      BIGINT NOT NULL REFERENCES klanten(klant_id),
  besteld_op    TIMESTAMP NOT NULL,
  status        TEXT NOT NULL,
  totaal_bedrag NUMERIC(12,2) NOT NULL
);

CREATE TABLE orderregels (
  bestelling_id BIGINT NOT NULL REFERENCES bestellingen(bestelling_id),
  regel_nr      INT NOT NULL,
  product_id    BIGINT NOT NULL,
  categorie     TEXT NOT NULL,
  aantal        INT NOT NULL,
  prijs         NUMERIC(12,2) NOT NULL,
  PRIMARY KEY (bestelling_id, regel_nr)
);

Indexen die in de praktijk vaak zinvol zijn:

CREATE INDEX idx_bestellingen_klant_datum
  ON bestellingen (klant_id, besteld_op);

CREATE INDEX idx_bestellingen_datum
  ON bestellingen (besteld_op);

CREATE INDEX idx_orderregels_bestelling
  ON orderregels (bestelling_id);

CREATE INDEX idx_orderregels_categorie
  ON orderregels (categorie);

Deel 1: Window functions (analytische functies)

Wat is een window function?

Een window function berekent een waarde per rij, maar gebruikt daarbij een set rijen (het “venster”) die je definieert met OVER (...). In tegenstelling tot een GROUP BY behoud je de detailrijen.

Belangrijke bouwstenen:


Rangschikken: ROW_NUMBER, RANK, DENSE_RANK

Use-case: “Geef per klant de meest recente bestelling.”

SELECT *
FROM (
  SELECT
    b.*,
    ROW_NUMBER() OVER (
      PARTITION BY b.klant_id
      ORDER BY b.besteld_op DESC
    ) AS rn
  FROM bestellingen b
  WHERE b.status = 'betaald'
) x
WHERE x.rn = 1;

Verschillen:

Voorbeeld: top-3 bestellingen per klant op basis van bedrag (met ties):

SELECT *
FROM (
  SELECT
    b.klant_id,
    b.bestelling_id,
    b.totaal_bedrag,
    DENSE_RANK() OVER (
      PARTITION BY b.klant_id
      ORDER BY b.totaal_bedrag DESC
    ) AS dr
  FROM bestellingen b
  WHERE b.status = 'betaald'
) t
WHERE t.dr <= 3
ORDER BY t.klant_id, t.dr, t.totaal_bedrag DESC;

Aggregaties zonder GROUP BY: SUM() OVER, AVG() OVER, COUNT() OVER

Use-case: toon per bestelling ook het totaal aantal bestellingen van die klant.

SELECT
  b.bestelling_id,
  b.klant_id,
  b.besteld_op,
  b.totaal_bedrag,
  COUNT(*) OVER (PARTITION BY b.klant_id) AS aantal_bestellingen_klant
FROM bestellingen b
WHERE b.status = 'betaald';

Belangrijk: dit is geen GROUP BY; je behoudt elke bestelling als rij.


Cumulatieve omzet: running total

Cumulatieve som per dag:

SELECT
  DATE_TRUNC('day', b.besteld_op) AS dag,
  SUM(b.totaal_bedrag) AS omzet_dag,
  SUM(SUM(b.totaal_bedrag)) OVER (
    ORDER BY DATE_TRUNC('day', b.besteld_op)
    ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
  ) AS omzet_cumulatief
FROM bestellingen b
WHERE b.status = 'betaald'
GROUP BY 1
ORDER BY 1;

Waarom staat er SUM(SUM(...)) OVER?
Omdat je eerst per dag aggregeert (SUM met GROUP BY), en daarna over die dagresultaten een window-som berekent.

ROWS vs RANGE:

Voor tijdreeksen is ROWS vaak voorspelbaarder.


Vergelijken met vorige/volgende rij: LAG en LEAD

Use-case: omzetverschil t.o.v. vorige bestelling per klant.

SELECT
  b.klant_id,
  b.bestelling_id,
  b.besteld_op,
  b.totaal_bedrag,
  LAG(b.totaal_bedrag) OVER (
    PARTITION BY b.klant_id
    ORDER BY b.besteld_op
  ) AS vorig_bedrag,
  b.totaal_bedrag
    - LAG(b.totaal_bedrag) OVER (
        PARTITION BY b.klant_id
        ORDER BY b.besteld_op
      ) AS delta_tov_vorige
FROM bestellingen b
WHERE b.status = 'betaald'
ORDER BY b.klant_id, b.besteld_op;

Praktische tip: als je LAG(...) twee keer gebruikt, kun je dit leesbaarder maken met een CTE of subquery zodat je het één keer berekent.

Met defaultwaarde:

LAG(b.totaal_bedrag, 1, 0) OVER (...)

Percentielen en mediaan: PERCENTILE_CONT

Use-case: mediaan orderwaarde per segment.

SELECT
  k.segment,
  PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY b.totaal_bedrag) AS mediaan_orderwaarde
FROM bestellingen b
JOIN klanten k ON k.klant_id = b.klant_id
WHERE b.status = 'betaald'
GROUP BY k.segment
ORDER BY k.segment;

PERCENTILE_CONT is een geavanceerde aggregatiefunctie (geen window function in deze vorm), maar zeer nuttig voor analyse. Sommige systemen hebben alternatieven; controleer je dialect.


Window frames: moving average (voortschrijdend gemiddelde)

Use-case: 7-daags voortschrijdend gemiddelde van dagomzet.

WITH dagomzet AS (
  SELECT
    DATE_TRUNC('day', besteld_op) AS dag,
    SUM(totaal_bedrag) AS omzet_dag
  FROM bestellingen
  WHERE status = 'betaald'
  GROUP BY 1
)
SELECT
  dag,
  omzet_dag,
  AVG(omzet_dag) OVER (
    ORDER BY dag
    ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
  ) AS omzet_7d_gem
FROM dagomzet
ORDER BY dag;

Waarom 6 PRECEDING?
Omdat je de huidige dag plus 6 dagen ervoor = 7 rijen. Let op: dit is rij-gebaseerd. Als er dagen ontbreken, is het niet exact “7 kalenderdagen”. Dan moet je eerst een kalender-tabel gebruiken of ontbrekende dagen opvullen.


Veelgemaakte fouten bij window functions

  1. Vergeten PARTITION BY: je berekent dan over de volledige dataset.
  2. Onbedoelde sortering: ORDER BY in de query is niet hetzelfde als ORDER BY in OVER.
  3. Frame-verwarring: standaardframe kan dialect-afhankelijk zijn. Wees expliciet bij cumulaties.
  4. Dubbele berekeningen: herhaal LAG/SUM OVER niet onnodig; gebruik CTE’s.

Deel 2: CTE’s (Common Table Expressions)

Waarom CTE’s?

CTE’s maken complexe queries:

Basisvorm:

WITH stap1 AS (...),
     stap2 AS (...)
SELECT ...
FROM stap2;

Voorbeeld: omzet per klant met cumulatieve omzet en segment

We bouwen dit modulair op.

WITH betaalde_bestellingen AS (
  SELECT
    bestelling_id,
    klant_id,
    besteld_op,
    totaal_bedrag
  FROM bestellingen
  WHERE status = 'betaald'
),
verrijking AS (
  SELECT
    b.*,
    k.segment
  FROM betaalde_bestellingen b
  JOIN klanten k ON k.klant_id = b.klant_id
),
met_cumulatief AS (
  SELECT
    v.*,
    SUM(v.totaal_bedrag) OVER (
      PARTITION BY v.klant_id
      ORDER BY v.besteld_op
      ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
    ) AS cumulatief_klant
  FROM verrijking v
)
SELECT
  klant_id,
  segment,
  bestelling_id,
  besteld_op,
  totaal_bedrag,
  cumulatief_klant
FROM met_cumulatief
ORDER BY klant_id, besteld_op;

Waarom deze opbouw helpt:


CTE’s en materialisatie: prestatie-implicaties

Afhankelijk van je database kan een CTE:

In PostgreSQL kun je (in recente versies) sturen met:

WITH verrijking AS MATERIALIZED (
  SELECT ...
)
SELECT ...

of:

WITH verrijking AS NOT MATERIALIZED (
  SELECT ...
)
SELECT ...

Wanneer materialisatie nuttig kan zijn:

Wanneer het nadelig kan zijn:

Praktische aanpak: meet met EXPLAIN (ANALYZE, BUFFERS).


Recursieve CTE: hiërarchieën (categorieboom, organisatiestructuur)

Stel je hebt een tabel met categorieën:

CREATE TABLE categorieen (
  categorie_id BIGINT PRIMARY KEY,
  parent_id    BIGINT NULL REFERENCES categorieen(categorie_id),
  naam         TEXT NOT NULL
);

Je wilt alle subcategorieën van een startcategorie ophalen:

WITH RECURSIVE boom AS (
  SELECT
    c.categorie_id,
    c.parent_id,
    c.naam,
    0 AS diepte,
    CAST(c.categorie_id AS TEXT) AS pad
  FROM categorieen c
  WHERE c.categorie_id = 10

  UNION ALL

  SELECT
    c.categorie_id,
    c.parent_id,
    c.naam,
    b.diepte + 1 AS diepte,
    b.pad || '>' || c.categorie_id AS pad
  FROM categorieen c
  JOIN boom b ON c.parent_id = b.categorie_id
)
SELECT *
FROM boom
ORDER BY pad;

Belangrijke punten:


Deel 3: Combineren van window functions en CTE’s voor echte analyses

Cohortanalyse: retentie per maand van eerste aankoop

Doel: groepeer klanten op basis van maand van eerste aankoop en meet in welke maanden ze terugkomen.

Stap-voor-stap:

  1. Bepaal eerste aankoopmaand per klant.
  2. Koppel elke bestelling aan cohort.
  3. Bereken “maand-offset” t.o.v. cohort.
  4. Tel unieke klanten per cohort en offset.
WITH betaalde AS (
  SELECT
    klant_id,
    DATE_TRUNC('month', besteld_op) AS maand
  FROM bestellingen
  WHERE status = 'betaald'
),
eerste AS (
  SELECT
    klant_id,
    MIN(maand) AS cohort_maand
  FROM betaalde
  GROUP BY klant_id
),
cohort_events AS (
  SELECT
    b.klant_id,
    e.cohort_maand,
    b.maand AS event_maand,
    (EXTRACT(YEAR FROM b.maand) - EXTRACT(YEAR FROM e.cohort_maand)) * 12
      + (EXTRACT(MONTH FROM b.maand) - EXTRACT(MONTH FROM e.cohort_maand)) AS maand_offset
  FROM betaalde b
  JOIN eerste e ON e.klant_id = b.klant_id
),
retentie AS (
  SELECT
    cohort_maand,
    maand_offset,
    COUNT(DISTINCT klant_id) AS klanten
  FROM cohort_events
  GROUP BY cohort_maand, maand_offset
),
cohort_grootte AS (
  SELECT
    cohort_maand,
    MAX(klanten) FILTER (WHERE maand_offset = 0) AS cohort_klanten
  FROM retentie
  GROUP BY cohort_maand
)
SELECT
  r.cohort_maand,
  r.maand_offset,
  r.klanten,
  cg.cohort_klanten,
  (r.klanten::NUMERIC / NULLIF(cg.cohort_klanten, 0)) AS retentie_ratio
FROM retentie r
JOIN cohort_grootte cg USING (cohort_maand)
ORDER BY r.cohort_maand, r.maand_offset;

Uitleg:

Optimalisatietip:


“Top-N per groep” met tie-breakers (klassieke analysevraag)

Vraag: “Geef per categorie de top 5 producten op omzet, met stabiele tie-breaker.”

We berekenen eerst omzet per product per categorie, dan rangschikken we.

WITH omzet_per_product AS (
  SELECT
    o.categorie,
    o.product_id,
    SUM(o.aantal * o.prijs) AS omzet
  FROM orderregels o
  JOIN bestellingen b ON b.bestelling_id = o.bestelling_id
  WHERE b.status = 'betaald'
  GROUP BY o.categorie, o.product_id
),
gerankt AS (
  SELECT
    opp.*,
    ROW_NUMBER() OVER (
      PARTITION BY opp.categorie
      ORDER BY opp.omzet DESC, opp.product_id ASC
    ) AS rn
  FROM omzet_per_product opp
)
SELECT
  categorie,
  product_id,
  omzet
FROM gerankt
WHERE rn <= 5
ORDER BY categorie, omzet DESC, product_id;

Waarom ROW_NUMBER en niet RANK?


Deel 4: Query-optimalisatie in de praktijk

Optimalisatie is context-afhankelijk: datavolume, verdeling, hardware, configuratie, en database-engine. Toch zijn er patronen die bijna altijd helpen.

1) Filter zo vroeg mogelijk (maar correct)

Slechte aanpak: eerst grote join, daarna filteren.

Betere aanpak: filter in een CTE of subquery vóór de join:

WITH betaalde_bestellingen AS (
  SELECT bestelling_id, klant_id, besteld_op, totaal_bedrag
  FROM bestellingen
  WHERE status = 'betaald'
    AND besteld_op >= NOW() - INTERVAL '90 days'
)
SELECT
  k.segment,
  SUM(b.totaal_bedrag) AS omzet
FROM betaalde_bestellingen b
JOIN klanten k ON k.klant_id = b.klant_id
GROUP BY k.segment;

Let op: “zo vroeg mogelijk” mag de logica niet veranderen. Bijvoorbeeld: bij LEFT JOIN kan een filter in WHERE de join effectief veranderen naar een INNER JOIN.


2) Begrijp INNER JOIN vs LEFT JOIN en filterplaatsing

Voorbeeld: je wilt alle klanten tonen, ook zonder bestellingen, met omzet 0.

Correct:

SELECT
  k.klant_id,
  k.segment,
  COALESCE(SUM(b.totaal_bedrag), 0) AS omzet
FROM klanten k
LEFT JOIN bestellingen b
  ON b.klant_id = k.klant_id
 AND b.status = 'betaald'
GROUP BY k.klant_id, k.segment;

Als je b.status = 'betaald' in WHERE zet, vallen klanten zonder bestellingen weg.


3) Indexen: kies op basis van filters en joins

Richtlijnen:

Voorbeeld: vaak “per klant, meest recente bestelling”:

CREATE INDEX idx_bestellingen_klant_besteldop_desc
  ON bestellingen (klant_id, besteld_op DESC);

Daarna kan deze query sterk versnellen:

SELECT
  klant_id,
  bestelling_id,
  besteld_op
FROM bestellingen
WHERE klant_id = 123
ORDER BY besteld_op DESC
LIMIT 1;

Let op: te veel indexen maken writes trager. Indexeer gericht.


4) Vermijd functies op geïndexeerde kolommen in filters

Dit kan indexgebruik blokkeren.

Minder goed:

WHERE DATE_TRUNC('day', besteld_op) = DATE '2026-02-01'

Beter (range-filter):

WHERE besteld_op >= TIMESTAMP '2026-02-01 00:00:00'
  AND besteld_op <  TIMESTAMP '2026-02-02 00:00:00'

Dit is vaak een groot verschil op grote tabellen.


5) EXPLAIN (ANALYZE, BUFFERS) lezen

In PostgreSQL:

EXPLAIN (ANALYZE, BUFFERS)
SELECT
  k.segment,
  SUM(b.totaal_bedrag)
FROM bestellingen b
JOIN klanten k ON k.klant_id = b.klant_id
WHERE b.status = 'betaald'
  AND b.besteld_op >= NOW() - INTERVAL '30 days'
GROUP BY k.segment;

Waar je op let:

Optimalisatie is iteratief: verander één ding, meet opnieuw.


6) Vermijd onnodige SELECT *

SELECT * haalt vaak meer kolommen op dan nodig, wat:

Kies expliciet kolommen, zeker in CTE’s die je later nog verwerkt.


7) Aggregaties: let op cardinaliteit en join-volgorde

Als je orderregels koppelt aan bestellingen, kan de rijcount exploderen. Vaak is het slim om eerst te aggregeren op het laagste niveau en pas daarna te joinen.

Voorbeeld: omzet per categorie per maand:

WITH betaalde AS (
  SELECT bestelling_id, DATE_TRUNC('month', besteld_op) AS maand
  FROM bestellingen
  WHERE status = 'betaald'
),
omzet_regels AS (
  SELECT
    o.bestelling_id,
    o.categorie,
    SUM(o.aantal * o.prijs) AS omzet
  FROM orderregels o
  GROUP BY o.bestelling_id, o.categorie
)
SELECT
  b.maand,
  r.categorie,
  SUM(r.omzet) AS omzet
FROM betaalde b
JOIN omzet_regels r ON r.bestelling_id = b.bestelling_id
GROUP BY b.maand, r.categorie
ORDER BY b.maand, r.categorie;

Waarom dit helpt:


8) Distinct: wees voorzichtig

COUNT(DISTINCT ...) is duur op grote datasets. Soms kun je het vervangen door:

Deduplicatie met ROW_NUMBER:

WITH gededupliceerd AS (
  SELECT *
  FROM (
    SELECT
      b.*,
      ROW_NUMBER() OVER (
        PARTITION BY b.klant_id, DATE_TRUNC('day', b.besteld_op), b.totaal_bedrag
        ORDER BY b.bestelling_id
      ) AS rn
    FROM bestellingen b
    WHERE b.status = 'betaald'
  ) x
  WHERE x.rn = 1
)
SELECT COUNT(*) FROM gededupliceerd;

Dit is alleen correct als je deduplicatie-regel klopt met je businessdefinitie.


Deel 5: Praktische patronen voor data-analyse

A) Segmentatie met percentielen (klanten in kwintielen op omzet)

WITH omzet_per_klant AS (
  SELECT
    klant_id,
    SUM(totaal_bedrag) AS omzet
  FROM bestellingen
  WHERE status = 'betaald'
  GROUP BY klant_id
),
met_bucket AS (
  SELECT
    o.*,
    NTILE(5) OVER (ORDER BY o.omzet DESC) AS kwintiel
  FROM omzet_per_klant o
)
SELECT
  kwintiel,
  COUNT(*) AS klanten,
  MIN(omzet) AS min_omzet,
  AVG(omzet) AS gem_omzet,
  MAX(omzet) AS max_omzet
FROM met_bucket
GROUP BY kwintiel
ORDER BY kwintiel;

NTILE(n) verdeelt rijen in n ongeveer gelijke groepen. Let op: bij veel gelijke waarden rond grenzen kan interpretatie lastig zijn.


B) Funnel-achtige analyse met window functions (volgorde van events)

Stel je hebt een tabel events(klant_id, event_tijd, event_type) en je wilt de eerste keer dat iemand “checkout” doet na “product_view”.

Een patroon is: rangschik events per klant en gebruik LEAD of self-joins op rijnummers. Conceptueel:

-- Voorbeeldstructuur
CREATE TABLE events (
  klant_id   BIGINT NOT NULL,
  event_tijd TIMESTAMP NOT NULL,
  event_type TEXT NOT NULL
);

WITH gerankt AS (
  SELECT
    e.*,
    ROW_NUMBER() OVER (
      PARTITION BY klant_id
      ORDER BY event_tijd
    ) AS rn
  FROM events e
),
views AS (
  SELECT klant_id, rn, event_tijd
  FROM gerankt
  WHERE event_type = 'product_view'
),
checkouts AS (
  SELECT klant_id, rn, event_tijd
  FROM gerankt
  WHERE event_type = 'checkout'
)
SELECT
  v.klant_id,
  v.event_tijd AS view_tijd,
  MIN(c.event_tijd) AS eerste_checkout_na_view
FROM views v
JOIN checkouts c
  ON c.klant_id = v.klant_id
 AND c.rn > v.rn
GROUP BY v.klant_id, v.event_tijd;

Dit kan zwaar zijn op grote eventtabellen; optimaliseer met:


Checklist: wanneer gebruik je wat?


Afsluiting

Geavanceerde SQL voor data-analyse draait om twee dingen: expressiviteit (window functions en CTE’s) en efficiëntie (optimalisatie en begrip van query-plannen). Als je deze technieken combineert, kun je analyses bouwen die zowel rijk zijn in inzichten als robuust in productie.

Als je wilt, kan ik op basis van jouw eigen tabellen en vragen: