Lima fungsi agregasi yang telah kita kuasai — COUNT, SUM, AVG, MIN, dan MAX — selama ini merangkum seluruh tabel menjadi satu baris hasil. Laporan harian toko kopi kita jarang sesederhana itu: pertanyaan yang lebih sering muncul berbunyi “berapa transaksi per kota?” atau “berapa unit terjual per produk?”. Klausa GROUP BY menjawab pertanyaan bergaya “per X” dengan memecah baris tabel menjadi kelompok berdasarkan satu kolom atau lebih, lalu menjalankan fungsi agregasi secara terpisah pada setiap kelompok. Pada chapter ini kita berlatih mengelompokkan satu kolom dan dua kolom, membuktikan aturan ketat kolom non-agregat, mengamati perilaku urutan hasil di MySQL 8, lalu menutup dengan pengelompokan berdasarkan ekspresi dan perlakuan NULL.

Menyiapkan Tabel Latihan #

Kita pakai kembali database latihan belajar_agregasi dengan tabel penjualan yang memuat empat belas transaksi. Pola datanya sama seperti chapter COUNT, ditambah dua baris baru supaya beberapa kombinasi kota dan produk muncul lebih dari satu kali — bahan penting untuk latihan pengelompokan dua kolom. Kolom pelanggan tetap dibuat boleh NULL dan enam baris sengaja dibiarkan tanpa nama pelanggan karena dibayar tunai:

sql
DROP DATABASE IF EXISTS belajar_agregasi;
CREATE DATABASE belajar_agregasi;
USE belajar_agregasi;

Output yang Dihasilkan:

text
Query OK, 0 rows affected, 1 warning (0.00 sec)

Query OK, 1 row affected (0.00 sec)

Database changed
sql
CREATE TABLE penjualan (
  id INT AUTO_INCREMENT PRIMARY KEY,
  tanggal DATE NOT NULL,
  kota VARCHAR(50) NOT NULL,
  produk VARCHAR(50) NOT NULL,
  jumlah INT NOT NULL,
  harga DECIMAL(10,2) NOT NULL,
  pelanggan VARCHAR(50) NULL
);

INSERT INTO penjualan (tanggal, kota, produk, jumlah, harga, pelanggan) VALUES
  ('2026-01-05', 'Jakarta',    'Kopi Gayo',   2, 55000.00, 'Budi'),
  ('2026-01-05', 'Bandung',    'Teh Melati',  3, 25000.00, 'Siti'),
  ('2026-01-06', 'Jakarta',    'Teh Melati',  1, 25000.00, NULL),
  ('2026-01-07', 'Surabaya',   'Kopi Toraja', 5, 62000.00, 'Andi'),
  ('2026-01-08', 'Bandung',    'Gula Aren',   4, 15000.00, NULL),
  ('2026-01-09', 'Jakarta',    'Gula Aren',   2, 15000.00, 'Rina'),
  ('2026-01-10', 'Yogyakarta', 'Kopi Gayo',   3, 55000.00, NULL),
  ('2026-01-11', 'Surabaya',   'Teh Melati',  2, 25000.00, 'Budi'),
  ('2026-01-12', 'Yogyakarta', 'Kopi Toraja', 1, 62000.00, NULL),
  ('2026-01-13', 'Jakarta',    'Kopi Toraja', 4, 62000.00, 'Siti'),
  ('2026-01-14', 'Bandung',    'Kopi Gayo',   1, 55000.00, NULL),
  ('2026-01-15', 'Surabaya',   'Gula Aren',   6, 15000.00, 'Rina'),
  ('2026-01-16', 'Jakarta',    'Kopi Gayo',   1, 55000.00, 'Dewi'),
  ('2026-01-17', 'Bandung',    'Teh Melati',  2, 25000.00, NULL);

Output yang Dihasilkan:

text
Query OK, 0 rows affected (0.00 sec)

Query OK, 14 rows affected (0.00 sec)
Records: 14  Duplicates: 0  Warnings: 0

Baris ketiga belas mengulang kombinasi Jakarta–Kopi Gayo dan baris keempat belas mengulang kombinasi Bandung–Teh Melati, sehingga pengelompokan berikutnya punya kelompok yang benar-benar berisi lebih dari satu baris. Seluruh tanggal tetap ditulis sebagai literal seperti '2026-01-05' agar hasil setiap contoh deterministik. Nilai kolom jumlah dan harga sengaja dipilih supaya penjumlahan per kelompok mudah dihitung ulang secara manual.

Satu Kolom: COUNT dan SUM per Kelompok #

Bentuk paling mendasar mengelompokkan baris berdasarkan satu kolom, lalu menghitung agregat untuk setiap kelompok:

sql
SELECT kota, COUNT(*) AS jumlah_transaksi, SUM(jumlah) AS total_unit
FROM penjualan
GROUP BY kota;

Output yang Dihasilkan:

text
+------------+------------------+------------+
| kota       | jumlah_transaksi | total_unit |
+------------+------------------+------------+
| Jakarta    |                5 |         10 |
| Bandung    |                4 |         10 |
| Surabaya   |                3 |         13 |
| Yogyakarta |                2 |          4 |
+------------+------------------+------------+
4 rows in set (0.00 sec)

Empat belas baris runtuh menjadi empat baris hasil — satu baris untuk setiap nilai kota yang berbeda. Mari kita bedah pembagian kerjanya baris per baris: klausa GROUP BY kota memerintahkan MySQL memindai seluruh baris lalu memasukkan setiap baris ke keranjang yang ditandai nilai kotanya, sehingga terbentuk keranjang Jakarta berisi lima baris, Bandung berisi empat baris, Surabaya tiga baris, dan Yogyakarta dua baris. Selanjutnya COUNT(*) menghitung isi tiap keranjang dan SUM(jumlah) menjumlahkan kolom jumlah di dalam keranjang yang sama — Jakarta menghasilkan 10 unit karena 2 + 1 + 2 + 4 + 1, Bandung juga 10 unit dari 3 + 4 + 1 + 2. Kolom kota boleh muncul di daftar SELECT tanpa fungsi agregat karena nilainya pasti tunggal di dalam satu keranjang — aturan penting yang kita uji di bagian berikutnya.

Kolom Non-Agregat Wajib Ikut GROUP BY #

MySQL 8 menjalankan mode only_full_group_by secara bawaan: setiap kolom di daftar SELECT yang tidak dibungkus fungsi agregat wajib muncul di klausa GROUP BY. Aturan ini mencegah hasil yang menyesatkan, dan pelanggarannya langsung ditolak server. Mari kita buktikan dengan sengaja menyertakan kolom produk yang tidak ikut dikelompokkan:

sql
-- @error
SELECT kota, produk FROM penjualan GROUP BY kota;

Output yang Dihasilkan:

text
Query OK, 0 rows affected (0.00 sec)

ERROR 1055 (42000): Expression #2 of SELECT list is not in GROUP BY clause and contains nonaggregated column 'belajar_agregasi.penjualan.produk' which is not functionally dependent on columns in GROUP BY clause; this is incompatible with sql_mode=only_full_group_by
Bye

Server menolak query dengan ERROR 1055 karena di dalam kelompok Jakarta nilai produk tidak tunggal — kelompok tersebut memuat Kopi Gayo, Teh Melati, Gula Aren, dan Kopi Toraja sekaligus, sehingga MySQL tidak punya dasar untuk memilih satu nilai mana pun sebagai “wakil” kelompok. Bentuk kedua pelanggaran terjadi ketika klausa GROUP BY sama sekali tidak ditulis padahal kolom non-agregat bercampur dengan fungsi agregat:

sql
-- @error
SELECT kota, COUNT(*) AS jumlah_transaksi FROM penjualan;

Output yang Dihasilkan:

text
Query OK, 0 rows affected (0.00 sec)

ERROR 1140 (42000): In aggregated query without GROUP BY, expression #1 of SELECT list contains nonaggregated column 'belajar_agregasi.penjualan.kota'; this is incompatible with sql_mode=only_full_group_by
Bye

Kali ini ERROR 1140 muncul karena COUNT(*) meruntuhkan seluruh tabel menjadi satu baris, sementara kota masih menyimpan empat nilai yang berbeda — konflik yang sama, hanya terjadi tanpa klausa GROUP BY. Solusi kedua galat ini selalu satu: tambahkan kolom non-agregat ke GROUP BY, atau bungkus kolom tersebut dengan fungsi agregat seperti MAX(produk).

MySQL 8 Tidak Lagi Mengurutkan Hasil GROUP BY #

Mari kita perhatikan kembali output pengelompokan per kota di atas: baris Jakarta tampil paling awal, padahal secara alfabetis Bandung semestinya lebih dulu. Urutan tersebut mengikuti urutan kemunculan pertama tiap nilai kota pada data kita, bukan hasil pengurutan. Perilaku ini bukan kebetulan aneh: MySQL 5.7 dan versi sebelumnya memang mengurutkan hasil GROUP BY secara implisit, namun pengurutan implisit itu sudah dihapus sejak MySQL 8.0, sehingga urutan hasil kini tidak dijamin sama sekali — bisa berubah ketika query plan berubah, misalnya setelah penambahan index. Untuk laporan yang urutannya berarti, klausa ORDER BY wajib ditulis secara eksplisit:

sql
SELECT kota, COUNT(*) AS jumlah_transaksi, SUM(jumlah) AS total_unit
FROM penjualan
GROUP BY kota
ORDER BY kota;

Output yang Dihasilkan:

text
+------------+------------------+------------+
| kota       | jumlah_transaksi | total_unit |
+------------+------------------+------------+
| Bandung    |                4 |         10 |
| Jakarta    |                5 |         10 |
| Surabaya   |                3 |         13 |
| Yogyakarta |                2 |          4 |
+------------+------------------+------------+
4 rows in set (0.00 sec)

Baris hasil kini terurut alfabetis dari Bandung hingga Yogyakarta dan akan selalu demikian setiap kali query dijalankan. Perhatikan bahwa ORDER BY berdiri sendiri terpisah dari GROUP BY: GROUP BY menentukan bagaimana baris dibagi menjadi kelompok, sedangkan ORDER BY menentukan bagaimana baris hasil ditampilkan.

Mengelompokkan dengan Dua Kolom #

Pengelompokan bisa memakai lebih dari satu kolom sekaligus: MySQL membentuk satu kelompok untuk setiap kombinasi nilai yang unik dari seluruh kolom yang disebut. Query berikut mengelompokkan berdasarkan pasangan kota dan produk:

sql
SELECT kota, produk, COUNT(*) AS jumlah_transaksi, SUM(jumlah) AS total_unit
FROM penjualan
GROUP BY kota, produk
ORDER BY kota, produk;

Output yang Dihasilkan:

text
+------------+-------------+------------------+------------+
| kota       | produk      | jumlah_transaksi | total_unit |
+------------+-------------+------------------+------------+
| Bandung    | Gula Aren   |                1 |          4 |
| Bandung    | Kopi Gayo   |                1 |          1 |
| Bandung    | Teh Melati  |                2 |          5 |
| Jakarta    | Gula Aren   |                1 |          2 |
| Jakarta    | Kopi Gayo   |                2 |          3 |
| Jakarta    | Kopi Toraja |                1 |          4 |
| Jakarta    | Teh Melati  |                1 |          1 |
| Surabaya   | Gula Aren   |                1 |          6 |
| Surabaya   | Kopi Toraja |                1 |          5 |
| Surabaya   | Teh Melati  |                1 |          2 |
| Yogyakarta | Kopi Gayo   |                1 |          3 |
| Yogyakarta | Kopi Toraja |                1 |          1 |
+------------+-------------+------------------+------------+
12 rows in set (0.00 sec)

Empat belas baris menghasilkan dua belas kelompok karena dua kombinasi muncul dua kali: kelompok Jakarta–Kopi Gayo berisi dua transaksi dengan total 3 unit (2 + 1), dan kelompok Bandung–Teh Melati juga berisi dua transaksi dengan total 5 unit (3 + 2). Sepuluh kombinasi lainnya masing-masing hanya muncul satu kali sehingga COUNT(*) bernilai 1. Urutan ORDER BY kota, produk mengurutkan berdasarkan kolom pertama lebih dulu, lalu kolom kedua untuk baris yang nilai kolom pertamanya sama — perhatikan blok Jakarta yang produknya terurut dari Gula Aren hingga Teh Melati.

GROUP BY pada Ekspresi #

Klausa GROUP BY tidak terbatas pada nama kolom — setiap ekspresi yang nilainya bisa dihitung per baris juga layak menjadi kunci pengelompokan. Kasus klasiknya adalah tabel log dengan kolom DATETIME yang ingin kita rekap per hari. Kita buat tabel kunjungan berisi sepuluh catatan akses halaman situs toko:

sql
CREATE TABLE kunjungan (
  id INT AUTO_INCREMENT PRIMARY KEY,
  waktu DATETIME NOT NULL,
  halaman VARCHAR(50) NOT NULL
);

INSERT INTO kunjungan (waktu, halaman) VALUES
  ('2026-01-12 08:15:00', '/beranda'),
  ('2026-01-12 08:42:00', '/produk'),
  ('2026-01-12 09:05:00', '/beranda'),
  ('2026-01-12 15:30:00', '/kontak'),
  ('2026-01-13 08:20:00', '/beranda'),
  ('2026-01-13 10:11:00', '/produk'),
  ('2026-01-13 10:44:00', '/beranda'),
  ('2026-01-14 09:00:00', '/beranda'),
  ('2026-01-14 16:25:00', '/kontak'),
  ('2026-01-14 16:58:00', '/produk');

Output yang Dihasilkan:

text
Query OK, 0 rows affected (0.00 sec)

Query OK, 10 rows affected (0.00 sec)
Records: 10  Duplicates: 0  Warnings: 0
sql
SELECT DATE(waktu) AS tanggal, COUNT(*) AS jumlah_kunjungan
FROM kunjungan
GROUP BY DATE(waktu)
ORDER BY DATE(waktu);

Output yang Dihasilkan:

text
+------------+------------------+
| tanggal    | jumlah_kunjungan |
+------------+------------------+
| 2026-01-12 |                4 |
| 2026-01-13 |                3 |
| 2026-01-14 |                3 |
+------------+------------------+
3 rows in set (0.00 sec)

Sepuluh baris dengan sepuluh waktu yang semuanya berbeda runtuh menjadi tiga baris hasil, satu baris per tanggal. Mekanismenya sama persis dengan pengelompokan kolom biasa: MySQL menghitung DATE(waktu) untuk setiap baris terlebih dahulu, lalu memasukkan baris ke keranjang sesuai hasil ekspresi tersebut — fungsi DATE() memotong komponen jam sehingga 08:15:00 dan 15:30:00 pada tanggal yang sama jatuh ke keranjang 2026-01-12. Daftar SELECT juga menampilkan DATE(waktu) — bukan kolom waktu mentah — supaya konsisten dengan kunci pengelompokan dan lolos aturan only_full_group_by.

NULL Membentuk Kelompok Sendiri #

Nilai NULL pada kolom pengelompokan tidak diabaikan, melainkan diperlakukan sebagai satu nilai kelompok tersendiri. Kita buktikan dengan mengelompokkan berdasarkan kolom pelanggan yang memuat enam baris NULL:

sql
SELECT pelanggan, COUNT(*) AS jumlah_transaksi
FROM penjualan
GROUP BY pelanggan
ORDER BY pelanggan;

Output yang Dihasilkan:

text
+-----------+------------------+
| pelanggan | jumlah_transaksi |
+-----------+------------------+
| NULL      |                6 |
| Andi      |                1 |
| Budi      |                2 |
| Dewi      |                1 |
| Rina      |                2 |
| Siti      |                2 |
+-----------+------------------+
6 rows in set (0.00 sec)

Enam transaksi tunai tanpa nama pelanggan terkumpul menjadi satu baris dengan label NULL, berdampingan dengan kelompok pelanggan bernama. Perilaku ini berlawanan dengan intuisi fungsi agregat: SUM dan COUNT(kolom) melewatkan NULL, tetapi GROUP BY justru mengumpulkan baris-baris NULL ke dalam kelompok yang sama. Baris NULL tampil paling atas karena pengurutan menaik di MySQL menempatkan NULL sebelum nilai apa pun — detail kecil yang penting diingat saat membaca laporan.

Membersihkan Database Latihan #

Rangkaian latihan selesai dan database latihan kita jatuhkan kembali:

sql
DROP DATABASE belajar_agregasi;

Output yang Dihasilkan:

text
Query OK, 2 rows affected (0.00 sec)

Referensi #

Agregasi per kelompok kini bisa kita susun: satu kolom, dua kolom, hingga ekspresi seperti DATE(waktu), lengkap dengan aturan kolom non-agregat dan kebiasaan ORDER BY yang wajib. Masalah berikutnya muncul ketika laporan hanya ingin menampilkan sebagian kelompok — misalnya kota dengan omzet di atas target tertentu. Kondisi semacam itu tidak bisa ditaruh di WHERE karena nilainya baru ada setelah agregasi berjalan; chapter berikutnya membahas HAVING, klausa penyaring hasil agregat yang mengisi kekosongan tersebut.