Laporan manajemen hampir tidak pernah berbentuk satu level saja. Satu lembar rekap penjualan memuat baris detail per pasangan kategori-kota, subtotal per kategori, dan satu baris total keseluruhan di kaki tabel. Dengan perkakas yang telah kita kuasai — GROUP BY, HAVING, FILTER — laporan seperti itu harus dirakit dari tiga query terpisah lalu dijahit UNION ALL, atau ditumpuk di sisi aplikasi. PostgreSQL (sejak versi 9.4) menawarkan cara langsung: klausa GROUP BY GROUPING SETS yang meminta beberapa level pengelompokan sekaligus dalam satu query. Chapter ini membahas sintaksnya, kesetaraannya dengan UNION ALL, cara membaca baris subtotal yang penuh NULL, dan fungsi grouping() yang membedakan NULL data dari NULL subtotal.

Tabel Latihan #

Tabel penjualan Toko Maju Jaya yang sama menemani kita lagi — dua belas transaksi, tiga kategori, tiga kota, dua metode NULL:

sql buat-tabel.sql
-- tabel latihan: penjualan Toko Maju Jaya, Januari-Februari 2026
CREATE TABLE penjualan (
    id            integer PRIMARY KEY,
    tanggal       date NOT NULL,
    kategori      text NOT NULL,
    produk        text NOT NULL,
    kota          text NOT NULL,
    jumlah        integer NOT NULL,
    harga_satuan  numeric(12,2) NOT NULL,
    metode_bayar  text
);

INSERT INTO penjualan (id, tanggal, kategori, produk, kota, jumlah, harga_satuan, metode_bayar) VALUES
    ( 1, DATE '2026-01-05', 'Minuman',    'Kopi Gayo', 'Jakarta',   10, 85000.00, 'tunai'),
    ( 2, DATE '2026-01-08', 'Makanan',    'Biskuit',   'Jakarta',   20, 25000.00, 'transfer'),
    ( 3, DATE '2026-01-12', 'Alat Tulis', 'Pulpen',    'Bandung',   15,  8000.00, 'qris'),
    ( 4, DATE '2026-01-15', 'Minuman',    'Kopi Gayo', 'Bandung',    8, 85000.00, 'transfer'),
    ( 5, DATE '2026-01-20', 'Makanan',    'Biskuit',   'Surabaya',  12, 25000.00, NULL),
    ( 6, DATE '2026-01-25', 'Alat Tulis', 'Pulpen',    'Jakarta',   30,  8000.00, 'tunai'),
    ( 7, DATE '2026-02-02', 'Minuman',    'Kopi Gayo', 'Surabaya',  12, 85000.00, 'qris'),
    ( 8, DATE '2026-02-06', 'Makanan',    'Biskuit',   'Bandung',   25, 25000.00, 'transfer'),
    ( 9, DATE '2026-02-10', 'Alat Tulis', 'Pulpen',    'Surabaya',   6,  8000.00, NULL),
    (10, DATE '2026-02-14', 'Minuman',    'Kopi Gayo', 'Jakarta',    5, 85000.00, 'qris'),
    (11, DATE '2026-02-18', 'Makanan',    'Biskuit',   'Jakarta',   18, 25000.00, 'tunai'),
    (12, DATE '2026-02-24', 'Alat Tulis', 'Pulpen',    'Bandung',    9,  8000.00, 'qris');

SELECT id, tanggal, kategori, produk, kota, jumlah, harga_satuan, metode_bayar
FROM penjualan
ORDER BY id;

Output yang Dihasilkan:

text
CREATE TABLE
INSERT 0 12
 id |  tanggal   |  kategori  |  produk   |   kota   | jumlah | harga_satuan | metode_bayar 
----+------------+------------+-----------+----------+--------+--------------+--------------
  1 | 2026-01-05 | Minuman    | Kopi Gayo | Jakarta  |     10 |     85000.00 | tunai
  2 | 2026-01-08 | Makanan    | Biskuit   | Jakarta  |     20 |     25000.00 | transfer
  3 | 2026-01-12 | Alat Tulis | Pulpen    | Bandung  |     15 |      8000.00 | qris
  4 | 2026-01-15 | Minuman    | Kopi Gayo | Bandung  |      8 |     85000.00 | transfer
  5 | 2026-01-20 | Makanan    | Biskuit   | Surabaya |     12 |     25000.00 | 
  6 | 2026-01-25 | Alat Tulis | Pulpen    | Jakarta  |     30 |      8000.00 | tunai
  7 | 2026-02-02 | Minuman    | Kopi Gayo | Surabaya |     12 |     85000.00 | qris
  8 | 2026-02-06 | Makanan    | Biskuit   | Bandung  |     25 |     25000.00 | transfer
  9 | 2026-02-10 | Alat Tulis | Pulpen    | Surabaya |      6 |      8000.00 | 
 10 | 2026-02-14 | Minuman    | Kopi Gayo | Jakarta  |      5 |     85000.00 | qris
 11 | 2026-02-18 | Makanan    | Biskuit   | Jakarta  |     18 |     25000.00 | tunai
 12 | 2026-02-24 | Alat Tulis | Pulpen    | Bandung  |      9 |      8000.00 | qris
(12 rows)

Struktur data ini identik dengan chapter HAVING dan FILTER Clause — peta jalan section sengaja demikian agar setiap klausa baru diuji pada medan yang sudah dikenal.

Tiga Level dalam Satu Query #

Sintaks GROUPING SETS menerima daftar pengelompokan dalam tanda kurung; setiap anggota daftar adalah satu “set” kolom yang dijadikan papan pembagi. Query berikut meminta tiga set sekaligus: (kategori, kota) untuk baris detail, (kategori) untuk subtotal kategori, dan () — set kosong — untuk total seluruh tabel:

-- GROUPING SETS: beberapa level pengelompokan dalam satu query

-- detail per kategori-kota, subtotal per kategori, dan grand total
SELECT kategori,
       kota,
       count(*) AS transaksi,
       sum(jumlah) AS total_item
FROM penjualan
GROUP BY GROUPING SETS ((kategori, kota), (kategori), ())
ORDER BY kategori NULLS LAST, kota NULLS LAST;

Output yang Dihasilkan:

text
  kategori  |   kota   | transaksi | total_item 
------------+----------+-----------+------------
 Alat Tulis | Bandung  |         2 |         24
 Alat Tulis | Jakarta  |         1 |         30
 Alat Tulis | Surabaya |         1 |          6
 Alat Tulis |          |         4 |         60
 Makanan    | Bandung  |         1 |         25
 Makanan    | Jakarta  |         2 |         38
 Makanan    | Surabaya |         1 |         12
 Makanan    |          |         4 |         75
 Minuman    | Bandung  |         1 |          8
 Minuman    | Jakarta  |         2 |         15
 Minuman    | Surabaya |         1 |         12
 Minuman    |          |         4 |         35
            |          |        12 |        170
(13 rows)

Hasilnya tiga belas baris yang merupakan gabungan tiga laporan: sembilan baris detail pasangan kategori-kota, tiga baris subtotal kategori (kolom kota kosong), dan satu baris total (kedua kolom kosong) dengan angka 12 transaksi dan 170 item. Mari kita baca anatominya:

  1. Set (kategori, kota) menghasilkan sembilan baris detail — perilaku yang persis sama dengan GROUP BY kategori, kota biasa dari chapter GROUP BY .
  2. Set (kategori) menghasilkan tiga baris subtotal. Untuk baris-baris ini kolom kota tidak berperan dalam pengelompokan, sehingga PostgreSQL mengisinya dengan NULL: itulah penanda “kolom ini tidak relevan pada baris subtotal”.
  3. Set () menghasilkan satu baris total keseluruhan — sama dengan agregat tanpa GROUP BY; kedua kolom grupnya NULL.

Klausa ORDER BY kategori NULLS LAST, kota NULLS LAST layak diperhatikan. PostgreSQL memperlakukan NULL sebagai nilai yang lebih besar daripada nilai apa pun, sehingga pada urutan naik NULL memang sudah jatuh di akhir — subtotal kategori mendarat tepat di bawah blok detailnya dan baris total menutup hasil. NULLS LAST tetap ditulis eksplisit sebagai pernyataan niat: bila suatu saat kolomnya dibalik ke DESC (yang bawaannya menempatkan NULL paling awal), urutan subtotal yang Anda rancang tidak ikut terbalik. Urutan ini memang wajib Anda atur sendiri karena GROUPING SETS tidak menjamin urutan baris apa pun.

Padanan UNION ALL: Mengapa Tidak Cukup Union? #

Query di atas bisa ditulis ulang dengan tiga SELECT yang dijembatani UNION ALL — dan membandingkan keduanya adalah cara tercepat memahami apa yang sebenarnya dikerjakan GROUPING SETS:

-- tanpa GROUPING SETS: tiga query UNION ALL dengan hasil yang sama

SELECT kategori, kota, count(*) AS transaksi, sum(jumlah) AS total_item
FROM penjualan
GROUP BY kategori, kota

UNION ALL

SELECT kategori, NULL AS kota, count(*), sum(jumlah)
FROM penjualan
GROUP BY kategori

UNION ALL

SELECT NULL AS kategori, NULL AS kota, count(*), sum(jumlah)
FROM penjualan
ORDER BY kategori NULLS LAST, kota NULLS LAST;

Output yang Dihasilkan:

text
  kategori  |   kota   | transaksi | total_item 
------------+----------+-----------+------------
 Alat Tulis | Bandung  |         2 |         24
 Alat Tulis | Jakarta  |         1 |         30
 Alat Tulis | Surabaya |         1 |          6
 Alat Tulis |          |         4 |         60
 Makanan    | Bandung  |         1 |         25
 Makanan    | Jakarta  |         2 |         38
 Makanan    | Surabaya |         1 |         12
 Makanan    |          |         4 |         75
 Minuman    | Bandung  |         1 |          8
 Minuman    | Jakarta  |         2 |         15
 Minuman    | Surabaya |         1 |         12
 Minuman    |          |         4 |         35
            |          |        12 |        170
(13 rows)

Tiga belas baris yang sama, angka demi angka. Bedanya ada pada tiga hal yang tak terlihat di output:

  1. Panjang query — versi UNION ALL mengulang klausa FROM, daftar kolom, dan agregat tiga kali; setiap perubahan laporan (tambah kolom, ganti agregat) harus disunting di tiga tempat yang harus tetap sinkron.
  2. NULL harus ditulis manual — NULL AS kota dan NULL AS kategori adalah tangan kita sendiri; lupa satu saja, UNION ALL gagal karena jumlah kolom tidak cocok.
  3. Pemindaian tabel — UNION ALL menjalankan tiga subquery (secara konseptual tiga kali melewati data), sementara GROUPING SETS merencanakan satu pemindaian yang menghitung semua level sekaligus. Pada dua belas baris latihan bedanya tak terasa; pada jutaan baris laporan harian, ini wilayah penghematan yang serius.

Set Bebas: Kekuatan yang Tidak Dimiliki ROLLUP #

Nama “set” pada GROUPING SETS bukan basa-basi: daftar itu boleh memuat kombinasi level apa pun, termasuk yang tidak membentuk hierarki. Contoh klasiknya — ringkasan per kategori dan per kota berdampingan tanpa baris detail dan tanpa total:

sql set-bebas.sql
-- set bebas: level yang tidak tersusun bertingkat, sesuatu yang ROLLUP tidak bisa

-- ringkasan per kategori DAN per kota dalam satu hasil, tanpa grand total
SELECT kategori,
       kota,
       count(*) AS transaksi
FROM penjualan
GROUP BY GROUPING SETS ((kategori), (kota))
ORDER BY kategori NULLS LAST, kota NULLS LAST;

Output yang Dihasilkan:

text
  kategori  |   kota   | transaksi 
------------+----------+-----------
 Alat Tulis |          |         4
 Makanan    |          |         4
 Minuman    |          |         4
            | Bandung  |         4
            | Jakarta  |         5
            | Surabaya |         3
(6 rows)

Enam baris keluar: tiga subtotal kategori (kota NULL) diikuti tiga subtotal kota (kategori NULL). Kedua level ini sederajat, bukan bertingkat — tidak ada cara menyusunnya sebagai ROLLUP karena ROLLUP selalu membentuk hierarki dari kolom yang tersusun. Inilah alasan GROUPING SETS disebut mekanisme umum: ROLLUP dan CUBE yang kita bahas di chapter berikutnya hanyalah pintasan untuk dua pola set yang sering dipakai.

Membaca Asal Baris dengan grouping() #

Baris subtotal memancarkan NULL di kolom yang tidak dikelompokkan — tetapi bagaimana jika data Anda sendiri berisi NULL? Dua NULL yang tampak sama itu bisa berbeda makna, dan fungsi grouping() hadir tepat untuk membedakannya. grouping(kolom) mengembalikan 0 bila kolom itu benar-benar menjadi kunci pengelompokan pada baris tersebut, dan 1 bila NULL pada baris itu murni hasil pengelompokan:

-- fungsi grouping(): 1 berarti kolom tidak ikut pengelompokan baris itu

SELECT kategori,
       kota,
       grouping(kategori) AS grup_kategori,
       grouping(kota)     AS grup_kota,
       count(*)           AS transaksi
FROM penjualan
GROUP BY GROUPING SETS ((kategori, kota), (kategori), ())
ORDER BY kategori NULLS LAST, kota NULLS LAST;

Output yang Dihasilkan:

text
  kategori  |   kota   | grup_kategori | grup_kota | transaksi 
------------+----------+---------------+-----------+-----------
 Alat Tulis | Bandung  |             0 |         0 |         2
 Alat Tulis | Jakarta  |             0 |         0 |         1
 Alat Tulis | Surabaya |             0 |         0 |         1
 Alat Tulis |          |             0 |         1 |         4
 Makanan    | Bandung  |             0 |         0 |         1
 Makanan    | Jakarta  |             0 |         0 |         2
 Makanan    | Surabaya |             0 |         0 |         1
 Makanan    |          |             0 |         1 |         4
 Minuman    | Bandung  |             0 |         0 |         1
 Minuman    | Jakarta  |             0 |         0 |         2
 Minuman    | Surabaya |             0 |         0 |         1
 Minuman    |          |             0 |         1 |         4
            |          |             1 |         1 |        12
(13 rows)

Bendera grup_kategori dan grup_kota membaca seperti kode level: 0|0 menandai baris detail, 0|1 menandai subtotal kategori, dan 1|1 menandai baris total. Karena grouping() adalah fungsi yang dievaluasi setelah pengelompokan, ia juga bisa dipakai di ORDER BY untuk mengurutkan level secara eksplisit — pola yang akan aktif kita pakai di chapter ROLLUP dan CUBE .

NULL Data vs NULL Subtotal dalam Praktik #

Kolom metode_bayar kita memuat dua baris NULL sungguhan — kondisi sempurna untuk mendemonstrasikan tabrakan dua jenis NULL. Query berikut mengelompokkan metode_bayar dua level: per nilai metode, plus total keseluruhan:

-- NULL dari data dan NULL dari pengelompokan tampak sama; grouping() membedakannya

-- metode_bayar memang berisi NULL pada 2 transaksi (id 5 dan 9)
SELECT metode_bayar,
       grouping(metode_bayar) AS grup_metode,
       count(*) AS transaksi
FROM penjualan
GROUP BY GROUPING SETS ((metode_bayar), ())
ORDER BY grouping(metode_bayar), metode_bayar NULLS LAST;

-- pola label rapi: ganti NULL dari grouping dengan teks, NULL dari data diberi keterangan
SELECT CASE WHEN grouping(metode_bayar) = 1
            THEN 'TOTAL SEMUA'
            ELSE coalesce(metode_bayar, '(belum dibayar)')
       END AS metode,
       count(*) AS transaksi
FROM penjualan
GROUP BY GROUPING SETS ((metode_bayar), ())
ORDER BY grouping(metode_bayar), metode_bayar NULLS LAST;

Output yang Dihasilkan:

text
 metode_bayar | grup_metode | transaksi 
--------------+-------------+-----------
 qris         |           0 |         4
 transfer     |           0 |         3
 tunai        |           0 |         3
              |           0 |         2
              |           1 |        12
(5 rows)

     metode      | transaksi 
-----------------+-----------
 qris            |         4
 transfer        |         3
 tunai           |         3
 (belum dibayar) |         2
 TOTAL SEMUA     |        12
(5 rows)

Query pertama memperlihatkan dua baris ber-metode_bayar NULL yang bisa membingungkan: satu dengan grup_metode = 0 — NULL-nya berasal dari data, kelompok dua transaksi yang belum dikonfirmasi metodenya — dan satu dengan grup_metode = 1 — NULL-nya berasal dari set kosong (), yaitu baris total dua belas transaksi. Query kedua merapikannya menjadi label yang tidak ambigu: coalesce() mengisi NULL data dengan keterangan (belum dibayar), sementara CASE mengganti NULL subtotal dengan TOTAL SEMUA. Pola CASE + grouping() inilah resep baku membuat hasil grouping sets layak dibaca manusia.

Dengan GROUPING SETS, satu query kini mampu memuat berbagai level ringkasan yang Anda tentukan sendiri. Dua pola set paling sering diminta — hierarki subtotal bertingkat dan semua kombinasi kolom — begitu umum sampai PostgreSQL menyediakan pintasan khusus untuk keduanya. Chapter berikutnya membedah keduanya sekaligus: ROLLUP dan CUBE .

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

buat-tabel.sql

Unduh berkas
sql
-- tabel latihan: penjualan Toko Maju Jaya, Januari-Februari 2026
CREATE TABLE penjualan (
    id            integer PRIMARY KEY,
    tanggal       date NOT NULL,
    kategori      text NOT NULL,
    produk        text NOT NULL,
    kota          text NOT NULL,
    jumlah        integer NOT NULL,
    harga_satuan  numeric(12,2) NOT NULL,
    metode_bayar  text
);

INSERT INTO penjualan (id, tanggal, kategori, produk, kota, jumlah, harga_satuan, metode_bayar) VALUES
    ( 1, DATE '2026-01-05', 'Minuman',    'Kopi Gayo', 'Jakarta',   10, 85000.00, 'tunai'),
    ( 2, DATE '2026-01-08', 'Makanan',    'Biskuit',   'Jakarta',   20, 25000.00, 'transfer'),
    ( 3, DATE '2026-01-12', 'Alat Tulis', 'Pulpen',    'Bandung',   15,  8000.00, 'qris'),
    ( 4, DATE '2026-01-15', 'Minuman',    'Kopi Gayo', 'Bandung',    8, 85000.00, 'transfer'),
    ( 5, DATE '2026-01-20', 'Makanan',    'Biskuit',   'Surabaya',  12, 25000.00, NULL),
    ( 6, DATE '2026-01-25', 'Alat Tulis', 'Pulpen',    'Jakarta',   30,  8000.00, 'tunai'),
    ( 7, DATE '2026-02-02', 'Minuman',    'Kopi Gayo', 'Surabaya',  12, 85000.00, 'qris'),
    ( 8, DATE '2026-02-06', 'Makanan',    'Biskuit',   'Bandung',   25, 25000.00, 'transfer'),
    ( 9, DATE '2026-02-10', 'Alat Tulis', 'Pulpen',    'Surabaya',   6,  8000.00, NULL),
    (10, DATE '2026-02-14', 'Minuman',    'Kopi Gayo', 'Jakarta',    5, 85000.00, 'qris'),
    (11, DATE '2026-02-18', 'Makanan',    'Biskuit',   'Jakarta',   18, 25000.00, 'tunai'),
    (12, DATE '2026-02-24', 'Alat Tulis', 'Pulpen',    'Bandung',    9,  8000.00, 'qris');

SELECT id, tanggal, kategori, produk, kota, jumlah, harga_satuan, metode_bayar
FROM penjualan
ORDER BY id;

fungsi-grouping.sql

Unduh berkas
sql
-- fungsi grouping(): 1 berarti kolom tidak ikut pengelompokan baris itu

SELECT kategori,
       kota,
       grouping(kategori) AS grup_kategori,
       grouping(kota)     AS grup_kota,
       count(*)           AS transaksi
FROM penjualan
GROUP BY GROUPING SETS ((kategori, kota), (kategori), ())
ORDER BY kategori NULLS LAST, kota NULLS LAST;

grouping-null-data.sql

Unduh berkas
sql
-- NULL dari data dan NULL dari pengelompokan tampak sama; grouping() membedakannya

-- metode_bayar memang berisi NULL pada 2 transaksi (id 5 dan 9)
SELECT metode_bayar,
       grouping(metode_bayar) AS grup_metode,
       count(*) AS transaksi
FROM penjualan
GROUP BY GROUPING SETS ((metode_bayar), ())
ORDER BY grouping(metode_bayar), metode_bayar NULLS LAST;

-- pola label rapi: ganti NULL dari grouping dengan teks, NULL dari data diberi keterangan
SELECT CASE WHEN grouping(metode_bayar) = 1
            THEN 'TOTAL SEMUA'
            ELSE coalesce(metode_bayar, '(belum dibayar)')
       END AS metode,
       count(*) AS transaksi
FROM penjualan
GROUP BY GROUPING SETS ((metode_bayar), ())
ORDER BY grouping(metode_bayar), metode_bayar NULLS LAST;

grouping-sets-dasar.sql

Unduh berkas
sql
-- GROUPING SETS: beberapa level pengelompokan dalam satu query

-- detail per kategori-kota, subtotal per kategori, dan grand total
SELECT kategori,
       kota,
       count(*) AS transaksi,
       sum(jumlah) AS total_item
FROM penjualan
GROUP BY GROUPING SETS ((kategori, kota), (kategori), ())
ORDER BY kategori NULLS LAST, kota NULLS LAST;

set-bebas.sql

Unduh berkas
sql
-- set bebas: level yang tidak tersusun bertingkat, sesuatu yang ROLLUP tidak bisa

-- ringkasan per kategori DAN per kota dalam satu hasil, tanpa grand total
SELECT kategori,
       kota,
       count(*) AS transaksi
FROM penjualan
GROUP BY GROUPING SETS ((kategori), (kota))
ORDER BY kategori NULLS LAST, kota NULLS LAST;

setara-union-all.sql

Unduh berkas
sql
-- tanpa GROUPING SETS: tiga query UNION ALL dengan hasil yang sama

SELECT kategori, kota, count(*) AS transaksi, sum(jumlah) AS total_item
FROM penjualan
GROUP BY kategori, kota

UNION ALL

SELECT kategori, NULL AS kota, count(*), sum(jumlah)
FROM penjualan
GROUP BY kategori

UNION ALL

SELECT NULL AS kategori, NULL AS kota, count(*), sum(jumlah)
FROM penjualan
ORDER BY kategori NULLS LAST, kota NULLS LAST;