# SQL voor Data-Analisten: Vensterfuncties, CTE's en Geavanceerde Query's > Een uitgebreide gids over SQL-vensterfuncties, Common Table Expressions (CTE's) en geavanceerde querypatronen voor data-analisten. Met praktische codevoorbeelden van ROW_NUMBER, RANK, LAG, LEAD, NTILE en recursieve CTE's. - Published: 2026-04-09 - Updated: 2026-04-09 - Author: SharpSkill - Tags: sql, data-analytics, window-functions, cte, interview - Reading time: 10 min --- SQL-vensterfuncties, CTE's (Common Table Expressions) en geavanceerde querypatronen vormen de kern van analytische SQL. Of het nu gaat om de voorbereiding op een sollicitatiegesprek als data-analist of het schrijven van complexe rapportagequery's — deze technieken transformeren onleesbare, diep geneste subquery's in overzichtelijke en performante SQL. > **Beknopt Overzicht** > > Vensterfuncties voeren berekeningen uit over een reeks rijen die gerelateerd zijn aan de huidige rij — zonder deze samen te vouwen tot één uitvoerrij zoals GROUP BY doet. In combinatie met CTE's worden complexe analytische query's leesbaar en onderhoudbaar. ## SQL-Vensterfuncties en de OVER-Clausule Een vensterfunctie past een berekening toe over een gedefinieerd "venster" van rijen. De OVER-clausule bepaalt welke rijen binnen dat venster vallen en hoe ze geordend worden. In tegenstelling tot aggregatiefuncties met GROUP BY behoudt een vensterfunctie elke individuele rij in de resultaatset. De algemene syntaxis volgt een consistent patroon in alle grote databases — PostgreSQL, MySQL 8+, BigQuery, SQL Server en Snowflake. ```sql -- window_function_syntax.sql SELECT employee_id, department, salary, RANK() OVER ( PARTITION BY department ORDER BY salary DESC ) AS dept_salary_rank, SUM(salary) OVER ( PARTITION BY department ORDER BY salary DESC ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) AS running_salary_total FROM employees; ``` PARTITION BY verdeelt de data in groepen (vergelijkbaar met GROUP BY, maar zonder rijen samen te vouwen). ORDER BY bepaalt de volgorde binnen elke partitie. De optionele frame-clausule (ROWS BETWEEN ...) beperkt het venster tot een specifiek bereik van rijen. Dit onderscheid is essentieel: waar GROUP BY het resultaat comprimeert tot één rij per groep, behoudt een vensterfunctie de volledige granulariteit van de dataset. Dat maakt het mogelijk om aggregatiewaarden naast individuele rijwaarden weer te geven — een patroon dat in vrijwel elke analytische rapportage terugkomt. ## ROW_NUMBER, RANK en DENSE_RANK Vergeleken Deze drie rangschikkingsfuncties lijken op het eerste gezicht identiek, maar gedragen zich anders wanneer er gelijke waarden (ties) voorkomen. Het kiezen van de verkeerde functie is een veelvoorkomende foutbron in analytische query's. ```sql -- ranking_comparison.sql SELECT product_name, category, revenue, ROW_NUMBER() OVER (ORDER BY revenue DESC) AS row_num, RANK() OVER (ORDER BY revenue DESC) AS rank_val, DENSE_RANK() OVER (ORDER BY revenue DESC) AS dense_rank_val FROM products; ``` | product_name | revenue | row_num | rank_val | dense_rank_val | |-------------|---------|---------|----------|----------------| | Widget A | 50000 | 1 | 1 | 1 | | Widget B | 50000 | 2 | 1 | 1 | | Widget C | 40000 | 3 | 3 | 2 | | Widget D | 30000 | 4 | 4 | 3 | ROW_NUMBER kent altijd een uniek oplopend geheel getal toe — zelfs bij gelijke waarden krijgt één rij willekeurig het lagere nummer. RANK kent hetzelfde nummer toe aan gelijke waarden, maar slaat de volgende nummers over (1, 1, 3). DENSE_RANK verwerkt gelijke waarden eveneens, maar slaat nooit nummers over (1, 1, 2). Voor top-N-query's waarin duplicaten dezelfde positie moeten delen, is DENSE_RANK doorgaans de juiste keuze. In de praktijk wordt ROW_NUMBER vaak gebruikt voor paginering en deduplicatie, terwijl RANK en DENSE_RANK beter geschikt zijn voor rapportages waarin de exacte positie van belang is. ## LAG en LEAD voor Periode-over-Periode Analyse LAG en LEAD bieden toegang tot data van voorgaande of volgende rijen zonder self-joins. Deze functies zijn onmisbaar voor het berekenen van periode-over-periode groei, het detecteren van trends en het identificeren van veranderingen in tijdreeksen. ```sql -- period_over_period.sql SELECT month, revenue, LAG(revenue, 1) OVER (ORDER BY month) AS prev_month_revenue, ROUND( (revenue - LAG(revenue, 1) OVER (ORDER BY month)) * 100.0 / NULLIF(LAG(revenue, 1) OVER (ORDER BY month), 0), 2 ) AS mom_growth_pct, LEAD(revenue, 1) OVER (ORDER BY month) AS next_month_revenue FROM monthly_sales ORDER BY month; ``` Het tweede argument van LAG/LEAD specificeert de offset (standaard is 1). Een derde optioneel argument biedt een standaardwaarde wanneer er geen rij op die offset bestaat — handig om NULL-waarden in de eerste of laatste rijen te vermijden. De NULLIF in de groeiberekening voorkomt deling-door-nul-fouten. Dit patroon wordt intensief gebruikt in financiële rapportages, waar maand-over-maand en kwartaal-over-kwartaal vergelijkingen standaard onderdeel zijn van dashboards. Het elimineert de noodzaak voor complexe self-joins die in oudere SQL-stijlen nodig waren. ## NTILE en Percentiel-Bucketing NTILE verdeelt rijen in een opgegeven aantal min of meer gelijke groepen. Data-analisten zetten het in voor kwartielanalyse, decielscoring en klantsegmentatie. ```sql -- customer_segmentation.sql SELECT customer_id, total_spend, NTILE(4) OVER (ORDER BY total_spend DESC) AS spend_quartile, NTILE(10) OVER (ORDER BY total_spend DESC) AS spend_decile FROM ( SELECT customer_id, SUM(order_total) AS total_spend FROM orders WHERE order_date >= '2025-01-01' GROUP BY customer_id ) customer_totals; ``` Kwartiel 1 bevat de klanten met de hoogste uitgaven, kwartiel 4 de laagste. Dit patroon sluit direct aan op RFM-segmentatie (Recency, Frequency, Monetary) die in marketinganalyse wordt toegepast. Door NTILE te combineren met meerdere dimensies ontstaan gedetailleerde klantprofielen die als basis dienen voor gerichte campagnes. ## Common Table Expressions: Geneste Subquery's Vervangen CTE's (WITH-clausules) splitsen complexe query's op in benoemde, leesbare stappen. Elke CTE fungeert als een tijdelijke benoemde resultaatset die alleen bestaat voor de duur van de query. Naast leesbaarheid vereenvoudigen CTE's het debugproces — elke stap kan afzonderlijk getest worden. ```sql -- cte_sales_analysis.sql WITH monthly_revenue AS ( SELECT DATE_TRUNC('month', order_date) AS month, product_category, SUM(amount) AS revenue, COUNT(DISTINCT customer_id) AS unique_customers FROM orders WHERE order_date >= '2025-01-01' GROUP BY DATE_TRUNC('month', order_date), product_category ), ranked_categories AS ( SELECT month, product_category, revenue, unique_customers, RANK() OVER ( PARTITION BY month ORDER BY revenue DESC ) AS category_rank FROM monthly_revenue ) SELECT month, product_category, revenue, unique_customers, category_rank FROM ranked_categories WHERE category_rank <= 3 ORDER BY month, category_rank; ``` Het bovenstaande voorbeeld illustreert hoe een CTE de logica opdeelt in twee heldere stappen: eerst de maandelijkse omzet per categorie berekenen, vervolgens een rangschikking toepassen. Zonder CTE's zou dezelfde logica als diep geneste subquery resulteren — aanzienlijk moeilijker te lezen en te onderhouden. Een bijkomend voordeel is dat CTE's meerdere keren in dezelfde query kunnen worden gerefereerd, wat codeduplicatie voorkomt wanneer dezelfde tussentabel in meerdere delen van de eindquery nodig is. ## Recursieve CTE's voor Hiërarchische Data Recursieve CTE's bieden oplossingen voor vraagstukken met hiërarchische of grafachtige data — organogrammen, categoriebomen, stuklijsten en padzoekquery's. ```sql -- recursive_org_chart.sql WITH RECURSIVE org_hierarchy AS ( SELECT employee_id, employee_name, manager_id, 1 AS depth, employee_name AS management_chain FROM employees WHERE manager_id IS NULL UNION ALL SELECT e.employee_id, e.employee_name, e.manager_id, oh.depth + 1, oh.management_chain || ' > ' || e.employee_name FROM employees e INNER JOIN org_hierarchy oh ON e.manager_id = oh.employee_id ) SELECT employee_id, employee_name, depth, management_chain FROM org_hierarchy ORDER BY management_chain; ``` De recursieve CTE begint met een ankerquery die de topniveaurijen selecteert (in dit geval medewerkers zonder manager). Het recursieve deel voegt vervolgens telkens het volgende niveau toe door te joinen op de eerder gevonden resultaten. Het resultaat is een complete hiërarchie met diepte-informatie en een aaneengeschakelde managementketen. > **Prestaties van Recursieve CTE's** > > Recursieve CTE's kunnen traag zijn op grote datasets. Voeg altijd een dieptelimiet toe (WHERE depth < 10) en zorg ervoor dat de joinkolom (manager_id) geïndexeerd is. Voor zeer diepe hiërarchieën zijn gematerialiseerde paden of nested set-patronen een beter alternatief. ## Geavanceerde Analytische Patronen: Gaps, Islands en Lopende Totalen Het gaps-and-islands-patroon identificeert opeenvolgende reeksen in data — actieve abonnementsperioden, opeenvolgende inlogdagen of ononderbroken productieruns. Dit patroon combineert ROW_NUMBER met datumberekeningen om groepen van aaneengesloten rijen te detecteren. ```sql -- consecutive_login_streaks.sql WITH login_days AS ( SELECT DISTINCT user_id, DATE(login_timestamp) AS login_date FROM user_sessions ), streaks AS ( SELECT user_id, login_date, login_date - (ROW_NUMBER() OVER ( PARTITION BY user_id ORDER BY login_date ))::int AS streak_group FROM login_days ) SELECT user_id, MIN(login_date) AS streak_start, MAX(login_date) AS streak_end, COUNT(*) AS streak_length FROM streaks GROUP BY user_id, streak_group HAVING COUNT(*) >= 3 ORDER BY streak_length DESC; ``` De kern van deze techniek berust op een wiskundig inzicht: wanneer een oplopende datum wordt verminderd met een oplopend rijnummer, levert een aaneengesloten reeks steeds dezelfde waarde op. Een onderbreking resulteert in een andere waarde, waardoor de groepen automatisch worden gescheiden. Dit patroon is bijzonder waardevol voor productanalyse (gebruikersretentie), financiele audits en operationele monitoring. ## Vensterfuncties Combineren met CASE voor Conditionele Analyse Door vensterfuncties te combineren met CASE-expressies ontstaan krachtige conditionele analyses. Dit maakt het mogelijk om voortschrijdende gemiddelden, cumulatieve kwartaaltotalen en uitbijterdetectie in één enkele query samen te voegen. ```sql -- conditional_analytics.sql SELECT order_date, product_category, amount, AVG(amount) OVER ( PARTITION BY product_category ORDER BY order_date ROWS BETWEEN 6 PRECEDING AND CURRENT ROW ) AS moving_avg_7d, SUM(amount) OVER ( PARTITION BY product_category, DATE_TRUNC('quarter', order_date) ORDER BY order_date ) AS qtd_cumulative, CASE WHEN amount > 2 * AVG(amount) OVER (PARTITION BY product_category) THEN 'OUTLIER' ELSE 'NORMAL' END AS outlier_flag FROM daily_sales ORDER BY product_category, order_date; ``` Het voortschrijdende 7-daags gemiddelde onthult kortetermijntrends binnen elke productcategorie. Het cumulatieve kwartaaltotaal toont de voortgang richting kwartaaldoelstellingen. De uitbijtervlag markeert transacties die meer dan het dubbele van het categorie-gemiddelde bedragen — een veelgebruikte methode voor fraudedetectie en datakwaliteitscontrole. > **Uitvoeringsvolgorde van Vensterfuncties** > > Vensterfuncties worden uitgevoerd na WHERE, GROUP BY en HAVING — maar vóór ORDER BY en LIMIT. Dit betekent dat vensterfuncties niet rechtstreeks in WHERE-clausules gebruikt kunnen worden. Om te filteren op het resultaat van een vensterfunctie, dient de query in een CTE of subquery te worden gewrapped. ## Prestatie-Optimalisatie voor Analytische Query's Bij het werken met grote datasets is het essentieel om vensterfuncties efficient in te zetten. Benoemde vensters (WINDOW-clausule) verminderen codeduplicatie en kunnen de query-optimalisatie verbeteren doordat de database-engine hetzelfde venster hergebruikt. ```sql -- named_window.sql SELECT department, employee_id, salary, RANK() OVER dept_window AS dept_rank, SUM(salary) OVER dept_window AS dept_total, AVG(salary) OVER dept_window AS dept_avg FROM employees WINDOW dept_window AS ( PARTITION BY department ORDER BY salary DESC ); ``` Door het venster eenmaal te definiëren en meerdere keren te hergebruiken, wordt de query niet alleen korter maar ook beter onderhoudbaar. Wanneer de partitielogica gewijzigd moet worden, hoeft dit slechts op één plek te gebeuren. Daarnaast zijn er enkele algemene prestatie-aanbevelingen voor analytische SQL: - **Filter vroeg**: pas WHERE-clausules toe voordat vensterfuncties worden geëvalueerd om het aantal te verwerken rijen te beperken. - **Indexeer partitiekolommen**: PARTITION BY-kolommen en ORDER BY-kolommen profiteren sterk van B-tree-indexen. - **Beperk het vensterbereik**: gebruik ROWS BETWEEN in plaats van het standaard onbegrensde bereik wanneer alleen een subset van rijen relevant is. - **Vermijd onnodige sorteringen**: elke unieke combinatie van PARTITION BY en ORDER BY kan een extra sorteerstap in het queryplan veroorzaken. ## Conclusie - Vensterfuncties (ROW_NUMBER, RANK, DENSE_RANK, LAG, LEAD, NTILE) opereren over rijensets zonder deze samen te vouwen — in tegenstelling tot GROUP BY-aggregaties - CTE's splitsen complexe query's op in benoemde, testbare stappen en vervangen diep geneste subquery's - Recursieve CTE's verwerken hiërarchische data zoals organogrammen en categoriebomen, maar vereisen dieptelimieten en geïndexeerde joinkolommen - Het gaps-and-islands-patroon (ROW_NUMBER + datumberekeningen) identificeert aaneengesloten reeksen in tijdreeksdata - Benoemde vensters (WINDOW-clausule) verminderen codeduplicatie en kunnen de query-optimalisatie verbeteren - Filter data in WHERE-clausules voordat vensterfuncties worden geëvalueerd om de prestaties op grote tabellen te behouden - Deze patronen komen frequent voor in [sollicitatiegesprekken voor data-analyse](/technologies/data-analytics) en zijn direct toepasbaar op dagelijkse rapportages, segmentatie en trendanalyse --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/nl/blog/data-analytics/sql-window-functions-ctes-advanced-queries