Window function ও CTE
এই পাঠে যা শিখবেন
- Window function-এর OVER, PARTITION BY, ORDER BY syntax
- ৬টি common window function — ROW_NUMBER, RANK, DENSE_RANK, LAG, LEAD, SUM OVER
- Running total, moving average, period-over-period growth
- CTE দিয়ে complex query-কে layered, readable করা
১ · Window function — কী ও কেন
Window functionWindow FunctionSQL-এর শক্তিশালী feature যা একটি row-এর "window" বা group-এর উপর হিসাব করে — কিন্তু row collapse করে না। SQL:2003 standard-এ যুক্ত। PostgreSQL, SQL Server, Oracle, MySQL ৮+ — সবার সাপোর্ট। aggregate function-এর extension — কিন্তু একটি critical পার্থক্য:
Aggregate (GROUP BY): n row → m row (m < n)। collapse।
Window (OVER): n row → n row। প্রতি row পাশে aggregate column যোগ।
একই query-তে individual row + group-level metric দু'টোই।
-- প্রতিটি অর্ডার + ক্যাটেগরির total revenue (একই query)
SELECT
o.order_id,
p.category,
o.total_amount,
SUM(o.total_amount) OVER (PARTITION BY p.category) AS category_total,
o.total_amount * 100.0
/ SUM(o.total_amount) OVER (PARTITION BY p.category) AS pct_of_category
FROM orders o
JOIN products p ON o.product_id = p.product_id
ORDER BY p.category, o.total_amount DESC;
২ · OVER clause-এর তিন অংশ
OVER (PARTITION BY ... ORDER BY ... ROWS BETWEEN ...) — তিনটি optional অংশ:
- PARTITION BY: "কী দিয়ে group করব" (GROUP BY-এর মতো, কিন্তু collapse নয়)।
- ORDER BY: "window-এ row-গুলো কী order-এ সাজাব" (running total, rank-এর জন্য essential)।
- ROWS/RANGE BETWEEN: "কোন row-গুলো current window-এ" (frame definition)।
৩ · Ranking functions — ROW_NUMBER, RANK, DENSE_RANK
-- প্রতিটি ক্যাটেগরির top-৩ পণ্য (sales-অনুযায়ী)
WITH ranked AS (
SELECT
category,
product_name,
sales,
ROW_NUMBER() OVER (PARTITION BY category ORDER BY sales DESC) AS rn,
RANK() OVER (PARTITION BY category ORDER BY sales DESC) AS rnk,
DENSE_RANK() OVER (PARTITION BY category ORDER BY sales DESC) AS dnk
FROM products
)
SELECT *
FROM ranked
WHERE rn <= 3
ORDER BY category, rn;
Sales: 100, 90, 90, 80
ROW_NUMBER: 1, 2, 3, 4 (arbitrary tie-break)
RANK: 1, 2, 2, 4 (gap after tie)
DENSE_RANK: 1, 2, 2, 3 (no gap)
৪ · LAG ও LEAD — আগের/পরের row দেখা
Time series-এ — "গত মাসের তুলনায় এ মাসে কত বেড়েছে" — এই pattern-এর backbone।
-- মাসিক revenue + আগের মাসের সাথে তুলনা
WITH monthly AS (
SELECT
DATE_TRUNC('month', order_date) AS month,
SUM(total_amount) AS revenue
FROM orders
WHERE order_date >= '2024-01-01'
GROUP BY DATE_TRUNC('month', order_date)
)
SELECT
month,
revenue,
LAG(revenue, 1) OVER (ORDER BY month) AS prev_month_revenue,
revenue - LAG(revenue, 1) OVER (ORDER BY month) AS month_over_month_diff,
ROUND(
100.0 * (revenue - LAG(revenue, 1) OVER (ORDER BY month))
/ NULLIF(LAG(revenue, 1) OVER (ORDER BY month), 0),
2
) AS mom_growth_pct
FROM monthly
ORDER BY month;
LAG NULL — division valid হয়।
৫ · Running total ও moving average
-- প্রতিটি দিনের সংখ্যা + cumulative running total + ৭-day moving average
WITH daily AS (
SELECT
order_date::date AS day,
SUM(total_amount) AS revenue
FROM orders
WHERE order_date >= '2024-01-01'
GROUP BY order_date::date
)
SELECT
day,
revenue,
SUM(revenue) OVER (
ORDER BY day
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS cumulative_revenue,
AVG(revenue) OVER (
ORDER BY day
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
) AS rolling_7day_avg
FROM daily
ORDER BY day;
Frame keywords: UNBOUNDED PRECEDING (start), CURRENT ROW, n PRECEDING, n FOLLOWING, UNBOUNDED FOLLOWING। Default frame ORDER BY থাকলে RANGE UNBOUNDED PRECEDING TO CURRENT ROW — সাবধান।
৬ · CTE — Common Table Expression
WITH clause দিয়ে query-কে নামকৃত block-এ ভাঙা। Subquery-র চেয়ে readable।
-- ৩ স্তরের CTE — clean, readable analytics query
WITH
recent_orders AS (
SELECT *
FROM orders
WHERE order_date >= '2024-01-01'
AND status = 'completed'
),
customer_metrics AS (
SELECT
customer_id,
COUNT(*) AS order_count,
SUM(total_amount) AS total_spent,
MAX(order_date) AS last_order
FROM recent_orders
GROUP BY customer_id
),
customer_segments AS (
SELECT
customer_id,
order_count,
total_spent,
last_order,
CASE
WHEN total_spent >= 50000 THEN 'VIP'
WHEN total_spent >= 10000 THEN 'Regular'
ELSE 'Casual'
END AS segment
FROM customer_metrics
)
SELECT
segment,
COUNT(*) AS customers,
ROUND(AVG(total_spent), 2) AS avg_spend,
ROUND(AVG(order_count), 2) AS avg_orders
FROM customer_segments
GROUP BY segment
ORDER BY avg_spend DESC;
৭ · Recursive CTE — hierarchy traversal
Org chart, category tree, graph traversal — সব recursive CTE-তে।
-- Employee hierarchy — CEO থেকে সব subordinate
WITH RECURSIVE org_tree AS (
-- base case: top-level (CEO)
SELECT id, name, manager_id, 0 AS level, name AS path
FROM employees
WHERE manager_id IS NULL
UNION ALL
-- recursive case: যাদের manager আগের level-এ
SELECT e.id, e.name, e.manager_id, ot.level + 1,
ot.path || ' > ' || e.name
FROM employees e
JOIN org_tree ot ON e.manager_id = ot.id
)
SELECT level, name, path
FROM org_tree
ORDER BY path;
৮ · Common patterns — quick reference
- Top-N per group:
ROW_NUMBER() OVER (PARTITION BY g ORDER BY x DESC)+ WHERE rn ≤ N। - Running total:
SUM(x) OVER (ORDER BY date)। - Moving average:
AVG(x) OVER (ORDER BY date ROWS BETWEEN 6 PRECEDING AND CURRENT)। - Period growth:
x - LAG(x) OVER (ORDER BY period)। - Rolling rank:
RANK() OVER (PARTITION BY day ORDER BY revenue DESC)। - Running max:
MAX(x) OVER (ORDER BY t)— peak-to-date। - First/last value:
FIRST_VALUE(x), LAST_VALUE(x) OVER (...)। - Percentile:
NTILE(4) OVER (ORDER BY x)— quartile।
৯ · Performance ও best practice
- Window function সাধারণত একটি extra sort/scan add করে — large dataset-এ partition column-এ index।
- একই PARTITION BY/ORDER BY-এর multiple window — DB optimizer একসাথে চালায়; ভিন্ন হলে multiple sort।
- CTE PostgreSQL ১২+ — inline (optimizer fold করে) by default; পুরোনো version "optimization fence"।
- Recursive CTE — termination condition থাকতেই হবে; না হলে infinite loop।
- Massive frame (UNBOUNDED PRECEDING) — memory expensive; necessary না হলে limited frame।
ভাবনার প্রশ্ন
প্রতিটি প্রশ্ন নিজে কিছুক্ষণ ভাবুন — তারপর "→ উত্তর" চাপুন।
প্র ০১ Window function ২০০৩ থেকে SQL standard-এ। তবু অনেক analyst এড়িয়ে যান, "subquery দিয়ে কাজ চলে"। Window function কেন paradigm shift — এর কোন কাজ subquery-তে impossible বা impractical?
"Window function লাগে না" — junior থেকে mid analyst-দের সবচেয়ে costly belief।
Pure SQL (pre-window) approach:
- Top-N per group → correlated subquery (slow)।
- Running total → self-join (quadratic complexity)।
- Moving average → multiple self-join + GROUP BY (painful)।
- LAG/LEAD → subquery with row_number trick।
Concrete comparison — running total:
Without window:
SELECT a.day, a.revenue,
(SELECT SUM(b.revenue)
FROM daily b
WHERE b.day <= a.day) AS cumulative
FROM daily a;
-- O(n²) — 10000 rows = 100M operations
With window:
SELECT day, revenue,
SUM(revenue) OVER (ORDER BY day) AS cumulative
FROM daily;
-- O(n log n) — 10000 rows = 130k operations
৭৭০x speedup on this scale alone।
Window-only operations:
- Frame definition: "last 7 days" — single SQL clause; subquery-তে date arithmetic + grouping।
- Multiple windows: 7-day MA + 30-day MA + cumulative — single query; subquery-তে ৩x effort।
- Gaps and islands: consecutive run detection — window অপরিহার্য।
- Sessionization: user activity gap-based session — without window, ugly procedural।
Why analyst এড়িয়ে যান:
- "Looks complex" — OVER, PARTITION BY syntax intimidating প্রথমে।
- Tutorials beginners-এ কম। Hidden gem।
- Old DB version — MySQL ৮.০-এর আগে support নেই।
- Habit — "subquery দিয়েই করি" mentality।
Real-world impact:
- Daraz analytics dashboard — pre-window 30-second load, post-window 2-second।
- bKash daily report — pre-window 4 separate jobs, post-window single CTE।
- Analyst productivity — same problem, half the LOC, double the readability।
মূল উপলব্ধি: Window function না জানা — "still using horse-and-buggy" সমান। ২০২৪-এর data analyst-এর জন্য non-negotiable। ১ সপ্তাহ-এর deep dive — careerlong reward।
প্র ০২ "OVER clause-এ ORDER BY default frame কী?" — interview question। এই default-এ কী trap আছে এবং কীভাবে এটি bug-এর উৎস?
"Default frame" — SQL-এর সবচেয়ে gotcha-laden topic-গুলোর একটি।
SQL standard default:
- OVER-এ ORDER BY থাকলে default frame:
RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW। - OVER-এ ORDER BY না থাকলে default frame:
RANGE BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING(পুরো partition)।
Trap 1 — RANGE vs ROWS:
-- ROWS — শুধু row position-এ
SUM(x) OVER (ORDER BY date ROWS BETWEEN 6 PRECEDING AND CURRENT ROW)
-- RANGE — value-based, equal value একসাথে
SUM(x) OVER (ORDER BY date RANGE BETWEEN '6 days' PRECEDING AND CURRENT ROW)
Same date-এর multiple row-এ behavior ভিন্ন।
Trap 2 — ORDER BY-এ ties:
SELECT day, revenue,
SUM(revenue) OVER (ORDER BY day) AS cumul
FROM daily;
-- যদি একই day-এ multiple row → RANGE default-এ একই day-এর সব row একসাথে aggregate
-- ROWS দিয়ে individually:
SUM(revenue) OVER (ORDER BY day ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)
Trap 3 — ORDER BY-হীন aggregate:
SELECT id, x, SUM(x) OVER () FROM data;
-- পুরো result-এ same value (grand total)
-- vs
SELECT id, x, SUM(x) OVER (ORDER BY id) FROM data;
-- running total — radically different
Trap 4 — LAST_VALUE puzzle:
-- "শেষ value" পেতে চাই
LAST_VALUE(x) OVER (ORDER BY date)
-- result: প্রতি row-এ "current" — কারণ default frame current row-এ থামে
-- সঠিক:
LAST_VALUE(x) OVER (
ORDER BY date
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
)
Trap 5 — performance:
- RANGE default — extra value comparison overhead।
- ROWS frame সাধারণত দ্রুত।
- Same partition, different ORDER BY — multiple sort।
Best practices:
- Frame সবসময় explicit লিখুন — assume করবেন না।
- ROWS use করুন যদি না RANGE specifically দরকার।
- LAST_VALUE/FIRST_VALUE use করলে frame অবশ্যই full partition।
- Documentation comment — "windowed by 7-day rolling"।
Database differences:
- PostgreSQL — strict standard।
- SQL Server ২০১২+ — ROWS support।
- MySQL ৮+ — ROWS/RANGE support।
- Older versions — frame ignored (silent bug)।
মূল উপলব্ধি: Default frame bug-এর invisible source। Senior practitioner-রা frame সবসময় explicit লেখেন। "সংক্ষিপ্ত SQL" valid concern নয় — প্রতিটি bug debug-এ ৫ গুণ বেশি সময় খরচ।
প্র ০৩ CTE vs subquery vs temp table vs materialized view — চারটি ভিন্ন approach। কোনটি কখন use করব? Performance ও maintainability-এর tradeoff কী?
SQL architecture-এর চিরন্তন প্রশ্ন।
(১) Subquery (inline):
SELECT *
FROM (
SELECT customer_id, SUM(amount) AS total
FROM orders
GROUP BY customer_id
) sub
WHERE sub.total > 10000;
- Pro: simple, no extra setup।
- Con: nested = unreadable, একই subquery use multi-times duplicate।
- Use: small one-time, single use।
(২) CTE (WITH):
WITH customer_total AS (
SELECT customer_id, SUM(amount) AS total
FROM orders
GROUP BY customer_id
)
SELECT * FROM customer_total WHERE total > 10000;
- Pro: readable, reusable in same query, recursive support।
- Con: some DB-তে materialized (slow for huge data); session-end লাগে।
- Use: complex multi-step query, recursive logic।
(৩) Temp table:
CREATE TEMP TABLE customer_total AS
SELECT customer_id, SUM(amount) AS total
FROM orders
GROUP BY customer_id;
CREATE INDEX ON customer_total(customer_id);
SELECT * FROM customer_total WHERE total > 10000;
- Pro: indexed, multi-query reuse, statistics for optimizer।
- Con: session-bound, cleanup burden, transaction issue।
- Use: repeated heavy compute in same session, ETL pipeline।
(৪) Materialized view:
CREATE MATERIALIZED VIEW customer_total AS
SELECT customer_id, SUM(amount) AS total
FROM orders
GROUP BY customer_id;
REFRESH MATERIALIZED VIEW customer_total; -- periodic
SELECT * FROM customer_total WHERE total > 10000;
- Pro: persistent, indexed, instant query।
- Con: stale data, refresh strategy, storage cost।
- Use: dashboard backend, expensive aggregation reused often।
Decision matrix:
- Single query, simple → subquery।
- Single query, complex/recursive → CTE।
- Multiple queries same session → temp table।
- Reused across sessions → materialized view।
- Real-time freshness needed → CTE/view।
- Performance critical, stale OK → materialized view।
Performance pitfall — CTE optimization fence:
- PostgreSQL ১১ এবং আগে — CTE materialized always; predicate push down হয় না।
- PostgreSQL ১২+ — inline by default;
MATERIALIZEDhint দিয়ে force। - SQL Server, Oracle — সাধারণত inline।
Modern alternative — dbt models:
- dbt-এ CTE-based modeling layered।
- Incremental model — "today's diff only" refresh।
- Test, doc, lineage — analytics engineering practice।
Bangladesh production patterns:
- Daraz analytics — CTE-heavy ad-hoc + materialized view dashboard।
- bKash fraud — temp table in batch ETL।
- Pathao — dbt + Snowflake stack (recent)।
মূল উপলব্ধি: "One size fits all" নয়। Query lifecycle (one-shot vs reused) + freshness + scale — তিনটি বিচার করে choice। Senior data engineer-এর kit-এ চারটিই থাকে।
প্র ০৪ আপনাকে দিতে হবে — "প্রতিটি গ্রাহকের প্রথম অর্ডার থেকে latest অর্ডার পর্যন্ত journey" — first product, last product, total spent, days as customer। SQL window function দিয়ে কীভাবে?
একটি classic "customer journey" query — RFM analysis-এর precursor।
Approach 1 — Multi-CTE:
WITH first_order AS (
SELECT
customer_id,
product_id AS first_product,
order_date AS first_date,
total_amount AS first_amount
FROM (
SELECT *,
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY order_date ASC
) AS rn
FROM orders
) t
WHERE rn = 1
),
last_order AS (
SELECT
customer_id,
product_id AS last_product,
order_date AS last_date
FROM (
SELECT *,
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY order_date DESC
) AS rn
FROM orders
) t
WHERE rn = 1
),
totals AS (
SELECT
customer_id,
SUM(total_amount) AS total_spent,
COUNT(*) AS order_count
FROM orders
GROUP BY customer_id
)
SELECT
c.customer_id,
c.name,
fp.name AS first_product_name,
lp.name AS last_product_name,
fo.first_date,
lo.last_date,
(lo.last_date - fo.first_date) AS days_as_customer,
t.total_spent,
t.order_count
FROM customers c
JOIN first_order fo ON c.customer_id = fo.customer_id
JOIN last_order lo ON c.customer_id = lo.customer_id
JOIN totals t ON c.customer_id = t.customer_id
JOIN products fp ON fo.first_product = fp.product_id
JOIN products lp ON lo.last_product = lp.product_id;
Approach 2 — FIRST_VALUE/LAST_VALUE:
WITH journey AS (
SELECT DISTINCT
customer_id,
FIRST_VALUE(product_id) OVER (
PARTITION BY customer_id ORDER BY order_date ASC
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
) AS first_product,
LAST_VALUE(product_id) OVER (
PARTITION BY customer_id ORDER BY order_date ASC
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
) AS last_product,
MIN(order_date) OVER (PARTITION BY customer_id) AS first_date,
MAX(order_date) OVER (PARTITION BY customer_id) AS last_date,
SUM(total_amount) OVER (PARTITION BY customer_id) AS total_spent,
COUNT(*) OVER (PARTITION BY customer_id) AS order_count
FROM orders
)
SELECT *,
(last_date - first_date) AS days_as_customer
FROM journey;
Note: LAST_VALUE-এর full frame critical।
Approach 3 — JSON aggregation:
SELECT
customer_id,
JSONB_AGG(
JSONB_BUILD_OBJECT('date', order_date, 'product', product_id)
ORDER BY order_date
) AS journey,
SUM(total_amount) AS total_spent
FROM orders
GROUP BY customer_id;
-- Full timeline as JSON array
Tradeoffs:
- Multi-CTE: readable, multi-JOIN cost।
- Window: single pass, অবলম্বন।
- JSON: flexible, application-side processing।
Adding richness — RFM:
- Recency: CURRENT_DATE - last_date।
- Frequency: order_count।
- Monetary: total_spent।
- NTILE(4)-এ R, F, M — segment 1-4।
- RFM combined → customer segments (champion, at-risk, lost, etc.)।
Real-world consideration:
- Customer "journey" শুধু order নয় — view, cart, search-ও।
- Multi-touch attribution — multi-table window।
- Sessionization — gap detection।
- Cohort overlay — first_date-এর month-এ partition।
মূল উপলব্ধি: Window function customer analytics-এর primary tool। FIRST_VALUE, LAST_VALUE, MIN/MAX OVER, COUNT OVER — single query-তে customer-360 view। SQL ২০০৩ standard ছাড়া এই kind of analysis painful ছিল।
অনুশীলন
-
Top-৫ per category: Window function দিয়ে প্রতিটি ক্যাটেগরির top-৫ best-selling পণ্য (revenue অনুযায়ী)।
WITH product_revenue AS ( SELECT p.category, p.product_id, p.name, SUM(o.total_amount) AS revenue FROM orders o JOIN products p ON o.product_id = p.product_id GROUP BY p.category, p.product_id, p.name ), ranked AS ( SELECT *, ROW_NUMBER() OVER ( PARTITION BY category ORDER BY revenue DESC ) AS rn FROM product_revenue ) SELECT category, name, revenue FROM ranked WHERE rn <= 5 ORDER BY category, rn; -
Month-over-month growth: মাসিক revenue, আগের মাসের revenue, এবং MoM growth %।
WITH monthly AS ( SELECT DATE_TRUNC('month', order_date) AS month, SUM(total_amount) AS revenue FROM orders WHERE order_date >= '2024-01-01' GROUP BY DATE_TRUNC('month', order_date) ) SELECT month, revenue, LAG(revenue, 1) OVER (ORDER BY month) AS prev_revenue, revenue - LAG(revenue, 1) OVER (ORDER BY month) AS diff, ROUND( 100.0 * (revenue - LAG(revenue, 1) OVER (ORDER BY month)) / NULLIF(LAG(revenue, 1) OVER (ORDER BY month), 0), 2 ) AS mom_pct FROM monthly ORDER BY month;NULLIF divide-by-zero এড়ায়। প্রথম মাসে LAG NULL — diff/pct-ও NULL, যা সঠিক।
-
Customer segmentation (RFM-lite): CTE দিয়ে প্রতিটি গ্রাহকের recency (days since last order), frequency (order count), monetary (total spent) — এবং তিন NTILE quartile।
WITH customer_rfm AS ( SELECT customer_id, CURRENT_DATE - MAX(order_date) AS recency_days, COUNT(*) AS frequency, SUM(total_amount) AS monetary FROM orders GROUP BY customer_id ), rfm_quartiles AS ( SELECT *, NTILE(4) OVER (ORDER BY recency_days ASC) AS r_score, NTILE(4) OVER (ORDER BY frequency DESC) AS f_score, NTILE(4) OVER (ORDER BY monetary DESC) AS m_score FROM customer_rfm ) SELECT customer_id, recency_days, frequency, monetary, r_score, f_score, m_score, CASE WHEN r_score = 1 AND f_score = 1 AND m_score = 1 THEN 'Champion' WHEN r_score <= 2 AND f_score <= 2 THEN 'Loyal' WHEN r_score = 4 AND f_score = 4 THEN 'Lost' ELSE 'Other' END AS segment FROM rfm_quartiles ORDER BY monetary DESC;NTILE(4): ৪টি equal-size bucket-এ split। Score 1 = best, 4 = worst (recency-এ ASC, frequency/monetary DESC)।
আরও পড়ুন
- পাঠ ০৭ · API ও web scraping পরবর্তী পাঠ SQL-এর বাইরে — API ও scraping দিয়ে নতুন data সংগ্রহ।
- পাঠ ০৫ · GROUP BY ও Aggregations আগের পাঠ Window-এর foundation — aggregate function।
- পাঠ ০৪ · JOIN এই পাঠের সাথে সম্পর্কিত Window query-তে JOIN-ও দরকার।
- সব AI Courses দেখুন ABCL TECH সব AI কোর্স।