SQL एग्रीगेशन और GROUP BY

COUNT, SUM, AVG, MIN या MAX जैसा aggregate function rows के एक set में किसी column को पढ़कर एक value लौटाता है। GROUP BY rows को groups में बाँटता है ताकि function हर group के लिए एक बार चले: SELECT status, COUNT(*) FROM orders GROUP BY status हर status की एक row उसके orders की संख्या के साथ लौटाती है।

इसी से tester SELECT, WHERE और JOIN के आधार पर database से अकेले record की बजाय records के set के बारे में पूछता है। फिर HAVING groups को filter करता है, और reconciliation checks दो जगह निकाले गए एक ही count या total की तुलना करते हैं।

यह क्यों ज़रूरी है

कई data defects एक-एक row में नहीं दिखते: ऐसा order जिसका total उसकी lines से अलग हो, ऐसा import जिसने 10,000 में से 9,998 rows load कीं, ऐसी report जो cancelled orders गिनती हो। हर row ठीक लगती है; defect तभी दिखता है जब set को गिनकर या जोड़कर expected value से मिलाया जाए।

एक aggregate query ऐसी report, invoice run या migration जाँच लेती है जिसे कोई row-by-row नहीं पढ़ सकता। खतरा गलत query का है: छूटा हुआ NULL, rows दोहराने वाला join या गलत clause में रखा filter false pass या false alarm देता है।

Aggregate functions: COUNT, SUM, AVG, MIN और MAX

Aggregate function rows के एक set के लिए एक value लौटाता है। GROUP BY के बिना WHERE पार करने वाली सारी rows एक set होती हैं, इसलिए query ठीक एक row लौटाती है, चाहे कुछ match न हुआ हो। Code block इस पूरे lesson में इस्तेमाल होने वाली छह rows की orders table दिखाता है।

Testing के लिए अहम बात NULL है: COUNT(*) को छोड़कर हर aggregate उसे ignore करता है। तीन orders की rating नहीं है, इसलिए COUNT(rating) 3 है और AVG(rating) 12 / 3 = 4 है, 12 / 6 = 2 नहीं। अगर business rule गायब value को शून्य मानता है, तो AVG(COALESCE(rating, 0)) लिखें और test case में यह बात दर्ज करें।

  • COUNT(*) rows गिनता है, COUNT(column) non-NULL values, और COUNT(DISTINCT column) अलग-अलग non-NULL values।
  • शून्य rows पर, या सिर्फ़ NULLs पर, SUM 0 नहीं बल्कि NULL लौटाता है: COALESCE(SUM(amount), 0) से तुलना करें।
  • SQL Server में integer column पर AVG integer लौटाता है; पहले decimal type में cast करें।
  • MIN और MAX numbers, dates और text पर चलते हैं, इसलिए range की तेज़ जाँच हैं, जैसे किसी import का सबसे पुराना created_at।
-- orders: the example table used in this lesson
-- order_id  customer_id  status     total_amount  rating
-- 1001      7            PAID             120.00       5
-- 1002      7            PAID              80.00    NULL
-- 1003      9            CANCELLED         50.00    NULL
-- 1004      9            PAID             200.00       4
-- 1005      12           PENDING           35.00    NULL
-- 1006      12           PAID              15.00       3

SELECT COUNT(*)                    AS all_rows,       -- 6
       COUNT(rating)               AS rated,          -- 3
       COUNT(DISTINCT customer_id) AS customers,      -- 3
       SUM(total_amount)           AS amount,         -- 500.00
       AVG(rating)                 AS avg_rating,     -- 4 = 12 / 3, not 12 / 6
       MIN(total_amount)           AS smallest,       -- 15.00
       MAX(total_amount)           AS largest         -- 200.00
FROM orders;

आज़माएँ: SQL INSERT जनरेटर आज़माएँ: SQL डेटासेट जनरेटर

GROUP BY: हर group के लिए result की एक row

GROUP BY grouping columns में एक जैसी value वाली rows को एक group में रखता है और हर aggregate हर group के लिए एक बार निकालता है। GROUP BY status छह orders को PAID (4 rows), CANCELLED (1) और PENDING (1) में बाँटता है; customer_id, status पर grouping हर combination का एक group बनाती है।

SELECT list का हर column या तो grouped हो या aggregated। SELECT status, order_id, COUNT(*) … GROUP BY status एक order_id माँगती है जबकि PAID में चार हैं। PostgreSQL, SQL Server और अपने default ONLY_FULL_GROUP_BY mode में MySQL इसे reject करते हैं; SQLite किसी arbitrary row की value लौटा देता है, इसलिए check संयोग से सही दिख सकता है।

  • Grouping column में NULL अपना अलग group बनाता है, जो अक्सर अपने आप में एक finding है।
  • Groups का कोई तय क्रम नहीं होता: report से तुलना से पहले ORDER BY जोड़ें।
  • Groups के totals का जोड़ पूरे total के बराबर होना चाहिए: 50.00 + 415.00 + 35.00 = 500.00 दिखाता है कि कोई row न खोई, न दो बार गिनी गई।
SELECT status,
       COUNT(*)          AS orders,
       SUM(total_amount) AS amount
FROM orders
GROUP BY status
ORDER BY status;

-- status     orders  amount
-- CANCELLED       1   50.00
-- PAID            4  415.00
-- PENDING         1   35.00

आज़माएँ: SQL क्वेरी वैलिडेटर आज़माएँ: SQL फ़ॉर्मेटर

WHERE rows को filter करता है, HAVING groups को

Grouped query एक logical क्रम में evaluate होती है: FROM और JOIN, WHERE, GROUP BY, aggregates, HAVING, SELECT, ORDER BY। Engine इसे अलग तरीके से चला सकता है, पर result हमेशा ऐसा होता है मानो यही क्रम माना गया हो। इसीलिए WHERE aggregate इस्तेमाल नहीं कर सकता, क्योंकि groups अभी बने ही नहीं, और HAVING कर सकता है।

अकेली rows की conditions, जैसे status या date range, WHERE में रखें, और group results की conditions, जैसे COUNT(*) > 1, HAVING में। नीचे की query एक से ज़्यादा paid orders वाले customers ढूँढती है: सिर्फ़ customer 7, दो orders और 200.00 के साथ। WHERE line हटाएँ तो customers 9 और 12 भी आ जाते हैं, क्योंकि अब उनके cancelled और pending orders भी गिने जाते हैं।

यही ढाँचा duplicates की आम जाँच है: SELECT email, COUNT(*) FROM customers GROUP BY email HAVING COUNT(*) > 1 हर वह email address दिखाती है जो एक से ज़्यादा customers पर दर्ज है।

छह orders पर paid-orders query का logical क्रम: WHERE 4 PAID rows रखता है, GROUP BY 3 customer groups बनाता है, और HAVING COUNT(*) > 1 सिर्फ़ customer 7 को रखता है।
  1. 1Rows पढ़ना6 rows: orders 1001 से 1006FROM orders
  2. 2Rows filter करना4 rows बचती हैं: 1001, 1002, 1004, 1006WHERE status = 'PAID'row filter

    शाखा: हटीं: 1003 CANCELLED, 1005 PENDING

  3. 3Groups बनाना3 groups: customer 7 (1001, 1002), 9 (1004), 12 (1006)GROUP BY customer_id
  4. 4Aggregates निकालना7: 2 orders, 200.00 · 9: 1 order, 200.00 · 12: 1 order, 15.00COUNT(*), SUM(total_amount)
  5. 5Groups filter करना1 group बचता है: customer 7HAVING COUNT(*) > 1group filter

    शाखा: हटे: customers 9 और 12, दोनों का एक-एक paid order

  6. 6Output बनानाResult की 1 row: customer 7, 2 orders, 200.00SELECT customer_id, …

चरण 4–6 के दौरान यहाँ aggregates मौजूद हैं: HAVING उन्हें जाँच सकता है, WHERE नहीं

Logical क्रम: engine query को अलग तरीके से चला सकता है, result वही रहता है।

-- Customers with more than one PAID order
SELECT customer_id,
       COUNT(*)          AS paid_orders,
       SUM(total_amount) AS paid_amount
FROM orders
WHERE status = 'PAID'      -- row filter, before grouping
GROUP BY customer_id
HAVING COUNT(*) > 1;       -- group filter, after aggregation

-- customer_id  paid_orders  paid_amount
-- 7                      2       200.00

Reconciliation checks: दो संख्याएँ जिन्हें मेल खाना चाहिए

Reconciliation एक ही count या total दो जगह निकालकर उनकी तुलना करता है। दूसरी जगह test oracle है, यानी expected result का source: document का header बनाम उसकी lines, import के बाद source system बनाम target, database बनाम कोई report या API response।

Check ऐसे लिखें कि सही database शून्य rows लौटाए: साझा key पर group करें और HAVING से सिर्फ़ असहमत groups रखें। लौटी हर row bug report का सबूत है। सिर्फ़ grand total नहीं, हर group की तुलना करें, क्योंकि grand total में दो उलटी गलतियाँ एक-दूसरे को काट सकती हैं।

  • Join fan-out: orders को उनकी lines से join करने पर हर order उतनी बार दोहराया जाता है जितनी उसकी lines हैं, इसलिए join के बाद SUM(o.total_amount) total बढ़ा देता है। Code block की तरह हर order की lines का aggregate निकालें।
  • गायब rows: बिना lines वाला order inner join से गायब हो जाता है। LEFT JOIN उसे रखता है, पर उसका SUM NULL होता है और total_amount <> NULL कभी true नहीं होता, इसलिए HAVING उसे फिर भी हटा देता है; COALESCE NULL को 0 बना देता है और mismatch दिख जाता है।
-- Rule: an order's total_amount equals the sum of its item lines
SELECT o.order_id,
       o.total_amount,
       COALESCE(SUM(oi.quantity * oi.unit_price), 0) AS lines_total
FROM orders o
LEFT JOIN order_items oi ON oi.order_id = o.order_id
GROUP BY o.order_id, o.total_amount
HAVING o.total_amount <> COALESCE(SUM(oi.quantity * oi.unit_price), 0);

-- Zero rows: the rule holds. Every row returned is a mismatch to report.

Worked example: daily sales report का reconciliation

Requirement: "Daily sales report हर दिन के PAID orders की संख्या और उनकी कुल राशि Riyadh time में दिखाती है।" created_at Riyadh time में stored है, इसलिए आप 1 से 3 सितंबर के paid orders को date के हिसाब से group करके हर दिन को report से मिलाते हैं। CAST(created_at AS DATE) समय वाला हिस्सा हटा देता है, इसलिए एक तारीख के सारे orders एक group में आते हैं।

दो दिन match करते हैं; 2 सितंबर नहीं। Count और amount दोनों का अंतर गलत amounts की बजाय extra rows का संकेत है, इसलिए उस दिन को हर status के हिसाब से group करके खोलें।

  • Database: 38 orders, 5720.50। Report: 40 orders, 5905.50। अंतर: 2 orders, 185.00।
  • Status के हिसाब से: CANCELLED में 2 orders और 185.00, ठीक-ठीक अंतर। आँकड़ों का मेल पक्का सबूत नहीं, मज़बूत संकेत है: अगर report अपने orders की list दिखा सकती है, तो जाँचें कि ये दोनों orders उसमें हैं।
  • Bug report: लगता है daily sales में payment के बाद cancel हुए orders भी गिने जा रहे हैं। दोनों queries और उनके results attach करें, और product owner से पूछें कि ऐसे orders daily sales में आने चाहिए या नहीं, क्योंकि requirement यह नहीं बताती।
-- 1. Expected values: PAID orders per day, 1 to 3 September
--    created_at holds Riyadh local time; in SQLite write date(created_at)
SELECT CAST(created_at AS DATE) AS sales_day,
       COUNT(*)                 AS paid_orders,
       SUM(total_amount)        AS paid_amount
FROM orders
WHERE status = 'PAID'
  AND created_at >= '2026-09-01' AND created_at < '2026-09-04'
GROUP BY CAST(created_at AS DATE)
ORDER BY sales_day;

-- sales_day   database        report          result
-- 2026-09-01  41  6150.00     41  6150.00     match
-- 2026-09-02  38  5720.50     40  5905.50     mismatch
-- 2026-09-03  44  6480.00     44  6480.00     match

-- 2. Drill down into 2 September, every status
SELECT status, COUNT(*) AS orders, SUM(total_amount) AS amount
FROM orders
WHERE created_at >= '2026-09-02' AND created_at < '2026-09-03'
GROUP BY status
ORDER BY status;

-- CANCELLED   2   185.00    <- exactly the difference
-- PAID       38  5720.50
-- PENDING     3   240.00

आज़माएँ: SQL VALUES जनरेटर आज़माएँ: CSV से SQL

आम गलतियाँ

  • Rows गिनने के लिए COUNT(column) इस्तेमाल करना; यह NULLs छोड़ देता है।
  • COALESCE के बिना SUM(...) की तुलना करना, जिससे NULL sum वाला group mismatch check से गायब हो जाता है।
  • न grouped न aggregated columns select करना और engine जो लौटाए उस पर भरोसा करना।
  • Rows को HAVING में filter करना, या WHERE में aggregate इस्तेमाल करने की कोशिश करना।
  • Header amounts को उनकी lines से join करने के बाद जोड़ना।
  • हर दिन, status या batch की बजाय सिर्फ़ grand total reconcile करना।

QA का नज़रिया

Aggregate checks हर उस feature पर लागू होते हैं जो संख्याएँ बनाता है: reports, dashboards, invoices, imports, exports और migrations। Test analysis में हर rule को ऐसे वाक्य में लिखें जो query बन सके, और product owner के साथ उसके सटीक filters तय करें: कौन-से statuses गिने जाएँगे, दिन किस time zone से तय होगा, test accounts बाहर रहेंगे या नहीं।

Queries read-only account से, dates या keys से सीमित करके चलाएँ। हर तुलना के दोनों पक्ष test case के साथ दर्ज करें: query, उसका result, दूसरा आँकड़ा और अंतर। Check fail हो तो बारीक GROUP BY से तब तक गहराई में जाएँ जब तक अंतर समझ न आए, और queries bug report में डालें ताकि developer उन्हें दोबारा चला सके।

अभ्यास

Invoices को उनकी lines से reconcile करें

आपका काम

एक invoicing service headers और lines store करती है। Rule: invoice का total उसकी lines के quantity * unit_price के जोड़ के बराबर है, और हर invoice में कम से कम एक line है। नीचे की tables पूरा test data हैं।

एक query लिखें जो सिर्फ़ rule तोड़ने वाले invoices लौटाए और काग़ज़ पर उसका result निकालें। फिर एक query लिखें जो हर customer के invoices गिने और उनके totals जोड़े।

सामग्री
invoices
invoice_id  customer_id  total
501         21           90.00
502         21           45.00
503         22           60.00
504         23           25.00

invoice_lines
line_id  invoice_id  quantity  unit_price
1        501         2         30.00
2        501         1         30.00
3        502         3         15.00
4        503         1         40.00
5        503         1         15.00
चरण
  1. Header, detail और join key पहचानें।
  2. ऐसा join type चुनें जो बिना lines वाले invoices को रखे।
  3. Invoice पर group करें, HAVING से mismatches रखें और NULL sum को handle करें।
  4. हर invoice को हाथ से जाँचें।
मॉडल उत्तर दिखाएँ
मॉडल उत्तर

Query: SELECT i.invoice_id, i.total, COALESCE(SUM(l.quantity * l.unit_price), 0) AS lines_total FROM invoices i LEFT JOIN invoice_lines l ON l.invoice_id = i.invoice_id GROUP BY i.invoice_id, i.total HAVING i.total <> COALESCE(SUM(l.quantity * l.unit_price), 0)।

Result: invoice 503 (total 60.00, lines 40.00 + 15.00 = 55.00) और invoice 504 (total 25.00, कोई line नहीं, lines_total 0)। Invoices 501 (90.00) और 502 (45.00) मेल खाते हैं। Inner join के साथ या COALESCE के बिना 504 छूट जाता। Amount वाला check ऐसा invoice भी नहीं पकड़ता जिसकी कोई line न हो और total 0.00 हो; HAVING में OR COUNT(l.line_id) = 0 जोड़ने से rule का दूसरा हिस्सा अलग से जाँचा जाता है।

हर customer के लिए: SELECT customer_id, COUNT(*) AS invoices, SUM(total) AS amount FROM invoices GROUP BY customer_id ORDER BY customer_id देती है 21: 2 invoices, 135.00; 22: 1, 60.00; 23: 1, 25.00, कुल 220.00। पहले lines से inner join करने पर customer 21 गलत तरीके से 3 invoices और 225.00 दिखाता, customer 22 दिखाता 2 और 120.00, और customer 23 गायब हो जाता।

अक्सर पूछे जाने वाले सवाल

WHERE और HAVING में क्या अंतर है?

WHERE group होने से पहले अकेली rows को filter करता है और aggregate functions इस्तेमाल नहीं कर सकता। HAVING aggregates निकलने के बाद पूरे groups को filter करता है, इसलिए वह COUNT, SUM और बाकी functions इस्तेमाल कर सकता है। एक query दोनों इस्तेमाल कर सकती है: WHERE उन rows के लिए जो calculation में आनी चाहिए, HAVING उन groups के लिए जो आप देखना चाहते हैं।

COUNT(*) और COUNT(column) अलग संख्या क्यों देते हैं?

COUNT(*) rows गिनता है; COUNT(column) सिर्फ़ वे rows गिनता है जिनमें वह column NULL नहीं है। दोनों का अंतर उस column में NULL वाली rows की संख्या है, जो उन fields की उपयोगी जाँच है जिन्हें required होना चाहिए।

क्या HAVING में SELECT का alias इस्तेमाल कर सकते हैं?

हर engine पर नहीं। Logical क्रम में HAVING, SELECT से पहले आता है, और PostgreSQL व SQL Server वहाँ alias reject करते हैं, जबकि MySQL और SQLite उसे स्वीकार करते हैं। Aggregate expression दोहराएँ, जैसा इस lesson के examples करते हैं, तो query हर engine पर चलेगी।

जिस group की उम्मीद थी वह result में क्यों नहीं है?

Group तभी बनता है जब कम से कम एक row GROUP BY तक पहुँचे। बिना orders वाला दिन, या ऐसा status जिसकी WHERE के बाद कोई row न बची, कोई row नहीं बनाता, 0 वाली row भी नहीं। गायब groups दिखाने के लिए expected days या keys की list से शुरू करें और aggregated data को उससे LEFT JOIN करें।

संदर्भ

  • PostgreSQL Documentation: SQL SyntaxThe PostgreSQL Global Development GroupPostgreSQL में SQL की लेक्सिकल संरचना: आइडेंटिफ़ायर, क्वोटिंग, स्ट्रिंग कॉन्स्टेंट और एस्केपिंग।
  • SQL As Understood By SQLiteSQLiteSQLite द्वारा स्वीकृत SQL व्याकरण, स्टेटमेंट दर स्टेटमेंट, सिंटैक्स डायग्राम के साथ।
  • ISO/IEC 9075-1:2023 — Information technology — Database languages SQL — Part 1: Framework (SQL/Framework)ISO/IEC · 2023अंतरराष्ट्रीय SQL मानक का framework भाग, जिसे PostgreSQL, MySQL, SQLite आदि की बोलियाँ अलग-अलग हद तक लागू करती हैं।
  • ISTQB GlossaryISTQBटेस्टिंग शब्दों की मानक परिभाषाएँ — टेस्ट केस, रिग्रेशन टेस्टिंग, सेवेरिटी, प्रायोरिटी, बाउंड्री वैल्यू एनालिसिस आदि।

Mutqan tools से अभ्यास करें

SQL स्टेटमेंट में सिंटैक्स की समस्याएँ, असंतुलित कोष्ठक, बिना बंद की स्ट्रिंग और जोखिम भरे पैटर्न जाँचें।

डेटाबेस

SQL क्वेरी को एकसमान कीवर्ड केसिंग, इंडेंटेशन और लाइन ब्रेक के साथ फ़ॉर्मेट करें (MySQL, PostgreSQL, SQL Server, Oracle, SQLite)।

डेटाबेस

टेबल परिभाषा, नमूना पंक्तियों, CSV या JSON से INSERT स्टेटमेंट बनाएँ।

डेटाबेस

किसी भी dialect के लिए वास्तविक जैसी टेस्ट पंक्तियों के साथ SQL seed स्क्रिप्ट (CREATE TABLE + INSERT) जनरेट करें।

टेस्ट डेटा

तालिका वाले डेटा को SQL VALUES सूची या VALUES-आधारित derived table में बदलें।

डेटाबेस

CSV डेटा को अनुमानित कॉलम टाइप के साथ CREATE TABLE और INSERT स्टेटमेंट में बदलें।

डेटाबेस

और गहराई से

इस topic में आपकी प्रगति

QA रोडमैप का हिस्सा