Kabupaten katalog produk butuh daftar semua varian kaos: tiga warna dikali tiga ukuran. Jadwal gudang butuh daftar semua shift: tiga hari dikali tiga jam mulai. Kedua tugas itu sama-sama membangkitkan kombinasi — pekerjaan yang membosankan bila ditulis manual, tetapi natural bagi CROSS JOIN: join yang tidak mencari pasangan sama sekali, melainkan memasangkan setiap baris tabel pertama dengan setiap baris tabel kedua. Karena tidak memiliki kondisi ON, CROSS JOIN juga satu-satunya jenis join yang tidak punya jalan keluar bila salah dipakai — hasilnya tidak error, hanya meledak secara diam-diam. Chapter ini membedah kedua wajahnya: alat pembangkit data yang elegan, dan bencana kartesian yang menunggu satu kondisi join yang terlupa.

Setiap Baris dengan Setiap Baris #

CROSS JOIN mengambil seluruh baris tabel kiri dan seluruh baris tabel kanan, lalu menghasilkan semua kombinasi keduanya — perkalian kartesian. Tidak ada kondisi ON yang dievaluasi karena memang tidak ada penyaringan: jumlah baris hasil selalu n × m, dengan n dan m jumlah baris masing-masing tabel. Perbandingannya dengan join yang sudah kita kenal:

Jenis joinKondisi pasanganBaris tanpa pasangan
INNER JOINdiuji lewat ONdibuang dari hasil
LEFT / RIGHT / FULL OUTER JOINdiuji lewat ONdipertahankan dengan NULL
CROSS JOINtidak ada — semua pasangan lolostidak bermakna; semua baris selalu berpasangan

Karena itulah CROSS JOIN tidak menerima klausa ON maupun USING — kondisi penggabungan tidak punya tempat karena tidak ada penggabungan yang perlu diuji.

Varian Produk: Kombinasi Warna dan Ukuran #

Penggunaan yang paling jujur dari CROSS JOIN adalah membangkitkan daftar kombinasi dari dua daftar kecil. Toko Maju Jaya hendak membuat katalog varian kaos dari daftar warna dan ukuran:

sql buat-tabel.sql
-- Varian produk: warna dan ukuran yang akan dikombinasikan
CREATE TABLE warna (
    id   integer PRIMARY KEY,
    nama text NOT NULL
);

CREATE TABLE ukuran (
    id   integer PRIMARY KEY,
    nama text NOT NULL
);

INSERT INTO warna (id, nama) VALUES
    (1, 'Hitam'),
    (2, 'Putih'),
    (3, 'Merah');

INSERT INTO ukuran (id, nama) VALUES
    (1, 'S'),
    (2, 'M'),
    (3, 'L');

SELECT * FROM warna ORDER BY id;
SELECT * FROM ukuran ORDER BY id;

Output yang Dihasilkan:

text
CREATE TABLE
CREATE TABLE
INSERT 0 3
INSERT 0 3
 id | nama  
----+-------
  1 | Hitam
  2 | Putih
  3 | Merah
(3 rows)

 id | nama 
----+------
  1 | S
  2 | M
  3 | L
(3 rows)

Dua daftar kecil ini menjadi sembilan varian:

sql cross-dasar.sql
-- CROSS JOIN: setiap warna dipasangkan dengan setiap ukuran
SELECT w.nama AS warna, u.nama AS ukuran
FROM warna w
CROSS JOIN ukuran u
ORDER BY w.id, u.id;

-- Jumlah baris hasil = jumlah baris warna x jumlah baris ukuran
SELECT count(*) AS jumlah_kombinasi
FROM warna w
CROSS JOIN ukuran u;

Output yang Dihasilkan:

text
 warna | ukuran 
-------+--------
 Hitam | S
 Hitam | M
 Hitam | L
 Putih | S
 Putih | M
 Putih | L
 Merah | S
 Merah | M
 Merah | L
(9 rows)

 jumlah_kombinasi 
------------------
                9
(1 row)

Sembilan baris hasil persis membentuk tabel 3×3: Hitam-S, Hitam-M, Hitam-L, Putih-S, dan seterusnya sampai Merah-L. Query kedua menghitung barisnya — 9 — dan angka itu bukan kebetulan: 3 baris warna dikali 3 baris ukuran. Rumus n × m ini bersifat selalu benar, dan menjadi alat diagnosis cepat: bila jumlah baris hasil join Anda aneh besar, cek apakah angkanya kebetulan sama dengan perkalian jumlah baris kedua tabel — bila ya, ada cartesian yang tidak diundang di dalam query.

Menyaring Hasil CROSS JOIN #

Kombinasi penuh jarang langsung dipakai apa adanya — katalog produksi mungkin mengecualikan varian tertentu. Filter bisnis ditempatkan di WHERE seperti biasa, bekerja setelah seluruh kombinasi terbentuk:

-- CROSS JOIN lalu disaring: kombinasi yang masuk katalog produksi
SELECT w.nama AS warna, u.nama AS ukuran
FROM warna w
CROSS JOIN ukuran u
WHERE w.nama <> 'Merah' AND u.nama <> 'S'
ORDER BY w.id, u.id;

Output yang Dihasilkan:

text
 warna | ukuran 
-------+--------
 Hitam | M
 Hitam | L
 Putih | M
 Putih | L
(4 rows)

Dari sembilan kombinasi, hanya empat yang lolos: warna merah tidak diproduksi dan ukuran S sedang dihentikan, menyisakan Hitam-M, Hitam-L, Putih-M, dan Putih-L. Pola “bangkitkan semua kombinasi, lalu saring” ini terbaca sebagai dua tahap yang eksplisit — dan itu pembeda pentingnya dengan INNER JOIN: warna CROSS JOIN ukuran WHERE ... menghasilkan baris yang sama dengan warna JOIN ukuran ON true WHERE ..., tetapi CROSS JOIN menyatakan niatnya terang-terangan: penggabungan memang tanpa syarat, penyaringan memang urusan WHERE.

Membangkitkan Data: Jadwal dengan generate_series #

Kekuatan sebenarnya CROSS JOIN muncul saat salah satu “tabelnya” bukan tabel simpanan, melainkan set-returning function seperti generate_series. Jadwal shift gudang tiga hari × tiga jam bisa dibangkitkan tanpa satu pun tabel sumber:

-- Membangkitkan jadwal shift gudang: 3 hari x 3 jam mulai = 9 baris
SELECT d.hari::date AS tanggal, j.jam AS jam_mulai
FROM generate_series(DATE '2026-03-02', DATE '2026-03-04', INTERVAL '1 day') AS d(hari)
CROSS JOIN generate_series(9, 11) AS j(jam)
ORDER BY d.hari, j.jam;

Output yang Dihasilkan:

text
  tanggal   | jam_mulai 
------------+-----------
 2026-03-02 |         9
 2026-03-02 |        10
 2026-03-02 |        11
 2026-03-03 |         9
 2026-03-03 |        10
 2026-03-03 |        11
 2026-03-04 |         9
 2026-03-04 |        10
 2026-03-04 |        11
(9 rows)

Mari kita bedah komponennya:

  1. generate_series(DATE '2026-03-02', DATE '2026-03-04', INTERVAL '1 day') AS d(hari) — function ini mengembalikan satu kolom deret nilai dari 2026-03-02 sampai 2026-03-04 dengan langkah satu hari; alias d(hari) memberi nama tabel dan kolom pada hasilnya. Literal tanggal tetap dipakai agar output deterministik.
  2. generate_series(9, 11) AS j(jam) — deret bilangan bulat 9, 10, 11; tiga jam mulai shift.
  3. CROSS JOIN — tiga baris hari dikali tiga baris jam menghasilkan sembilan baris jadwal, terurut rapi menurut tanggal lalu jam. Kolom d.hari::date memangkas bagian waktu 00:00:00 dari hasil deret sehingga tampilan bersisa tanggal saja.

Pola yang sama melahirkan kisi kalender (hari × slot), matriks pengujian (kasus × lingkungan), hingga tabel perkalian untuk latihan anak — semua tanpa menyimpan satu baris pun kombinasi di database. Daftar lengkap set-returning function lainnya (generate_series untuk timestamp, string_to_table, dan sebagainya) tersedia di dokumentasi resmi.

Bencana Kartesian: Kondisi Join yang Hilang #

Sisi gelap CROSS JOIN adalah kakaknya yang tanpa disengaja: comma join yang kehilangan WHERE. Skema pelanggan–pesanan yang setia menemani section ini kembali dipakai sebagai korban:

-- Skema pelanggan-pesanan kembali dipakai sebagai korban bencana
CREATE TABLE pelanggan (
    id    integer PRIMARY KEY,
    nama  text NOT NULL,
    kota  text NOT NULL,
    email text NOT NULL UNIQUE
);

CREATE TABLE pesanan (
    id           integer PRIMARY KEY,
    pelanggan_id integer REFERENCES pelanggan (id),
    tanggal      date NOT NULL,
    total        numeric(12, 2) NOT NULL
);

INSERT INTO pelanggan (id, nama, kota, email) VALUES
    (1, 'Budi Santoso', 'Bandung',  '[email protected]'),
    (2, 'Siti Rahayu',  'Jakarta',  '[email protected]'),
    (3, 'Andi Wijaya',  'Surabaya', '[email protected]'),
    (4, 'Dewi Lestari', 'Medan',    '[email protected]');

INSERT INTO pesanan (id, pelanggan_id, tanggal, total) VALUES
    (101, 1,    DATE '2026-01-05', 150000.00),
    (102, 1,    DATE '2026-01-18',  75000.00),
    (103, 2,    DATE '2026-01-09', 220000.00),
    (104, 4,    DATE '2026-02-02',  50000.00),
    (105, NULL, DATE '2026-02-10',  30000.00);

-- Comma join tanpa WHERE: 4 pelanggan x 5 pesanan = 20 pasangan nonsense
SELECT count(*) AS jumlah_baris
FROM pelanggan, pesanan;

-- Cuplikan pasangannya: setiap pelanggan "memiliki" semua pesanan
SELECT p.nama, ps.id AS pesanan, ps.total
FROM pelanggan p, pesanan ps
ORDER BY p.id, ps.id
LIMIT 4;

Output yang Dihasilkan:

text
CREATE TABLE
CREATE TABLE
INSERT 0 4
INSERT 0 5
 jumlah_baris 
--------------
           20
(1 row)

     nama     | pesanan |   total   
--------------+---------+-----------
 Budi Santoso |     101 | 150000.00
 Budi Santoso |     102 |  75000.00
 Budi Santoso |     103 | 220000.00
 Budi Santoso |     104 |  50000.00
(4 rows)

Query penghitung mengembalikan 20 — tepat hasil 4 × 5 — padahal tidak satu pun kondisi penggabungan yang ditulis. Cuplikan empat baris pertamanya memperlihatkan kerusakan maknanya: Budi Santoso tampil sebagai “pemilik” pesanan 101, 102, 103, dan 104 sekaligus, padahal 103 adalah pesanan Siti. Total laporan dari query seperti ini — misalnya sum(ps.total) — akan menghitung setiap pesanan empat kali (sekali per pelanggan), sehingga angka penjualan membengkak tanpa satu error pun yang memberi peringatan.

Skala bencana mengikuti perkalian: dua tabel 1.000 baris menghasilkan sejuta baris; menambahkan tabel ketiga 1.000 baris melipatgandakannya lagi menjadi satu miliar. Itulah alasan comma join multi-tabel tanpa WHERE adalah penyebab klasik query “menggantung” di sistem produksi.

Pengaman Explicit JOIN #

Ada kabar baik: PostgreSQL menolak bentuk JOIN yang kehilangan kondisinya. Sintaks eksplisit mewajibkan ON atau USING:

-- PostgreSQL menolak JOIN tanpa ON atau USING: bukan diam-diam jadi cross join
SELECT p.nama, ps.id AS pesanan
FROM pelanggan p
JOIN pesanan ps;

Output yang Dihasilkan:

text
psql:join-tanpa-on.sql:4: ERROR:  syntax error at or near ";"
LINE 3: JOIN pesanan ps;
                       ^

Parser berhenti tepat setelah JOIN pesanan ps dan melaporkan syntax error at or near ";" — tanda kutip error mengarah ke titik di mana klausa ON seharusnya hadir. Perilaku ini berbeda dari sebagian database lain yang membiarkan INNER JOIN tanpa ON melunak menjadi cross join secara diam-diam; PostgreSQL memilih menolaknya di muka. Konsekuensinya:

Bentuk penulisanPerilaku PostgreSQL
a CROSS JOIN bcartesian — memang diminta secara eksplisit
a JOIN b tanpa ON/USINGditolak sebagai syntax error
a, b (comma join) tanpa WHEREcartesian senyap — sumber bencana klasik

Dua pengaman terakhir praktis: tulis penggabungan selalu dengan JOIN ... ON sehingga kondisi hilang langsung tertangkap parser, dan perlakukan setiap kemunculan CROSS JOIN dalam review kode sebagai pernyataan niat yang harus bisa dijelaskan — “kita memang ingin semua kombinasi”. Comma join, sebagaimana dibahas di chapter Konsep Relasi Antartabel , sebaiknya pensiun dari kode baru sama sekali.

CROSS JOIN menutup jenis join berdasarkan cara memasangkan baris: yang menyaring pasangan (INNER), yang melindungi sisi (LEFT, RIGHT, FULL OUTER), dan yang tidak menyaring sama sekali. Satu bentuk terakhir masih tersisa — join yang kedua “tabelnya” adalah tabel yang sama: SELF JOIN, pola standar untuk data berhierarki seperti relasi karyawan dengan atasan yang sudah sekilas kita lihat di chapter Foreign Key.

Referensi #

Berkas Sumber

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

bencana-kartesian.sql

Unduh berkas
sql
-- Skema pelanggan-pesanan kembali dipakai sebagai korban bencana
CREATE TABLE pelanggan (
    id    integer PRIMARY KEY,
    nama  text NOT NULL,
    kota  text NOT NULL,
    email text NOT NULL UNIQUE
);

CREATE TABLE pesanan (
    id           integer PRIMARY KEY,
    pelanggan_id integer REFERENCES pelanggan (id),
    tanggal      date NOT NULL,
    total        numeric(12, 2) NOT NULL
);

INSERT INTO pelanggan (id, nama, kota, email) VALUES
    (1, 'Budi Santoso', 'Bandung',  '[email protected]'),
    (2, 'Siti Rahayu',  'Jakarta',  '[email protected]'),
    (3, 'Andi Wijaya',  'Surabaya', '[email protected]'),
    (4, 'Dewi Lestari', 'Medan',    '[email protected]');

INSERT INTO pesanan (id, pelanggan_id, tanggal, total) VALUES
    (101, 1,    DATE '2026-01-05', 150000.00),
    (102, 1,    DATE '2026-01-18',  75000.00),
    (103, 2,    DATE '2026-01-09', 220000.00),
    (104, 4,    DATE '2026-02-02',  50000.00),
    (105, NULL, DATE '2026-02-10',  30000.00);

-- Comma join tanpa WHERE: 4 pelanggan x 5 pesanan = 20 pasangan nonsense
SELECT count(*) AS jumlah_baris
FROM pelanggan, pesanan;

-- Cuplikan pasangannya: setiap pelanggan "memiliki" semua pesanan
SELECT p.nama, ps.id AS pesanan, ps.total
FROM pelanggan p, pesanan ps
ORDER BY p.id, ps.id
LIMIT 4;

buat-tabel.sql

Unduh berkas
sql
-- Varian produk: warna dan ukuran yang akan dikombinasikan
CREATE TABLE warna (
    id   integer PRIMARY KEY,
    nama text NOT NULL
);

CREATE TABLE ukuran (
    id   integer PRIMARY KEY,
    nama text NOT NULL
);

INSERT INTO warna (id, nama) VALUES
    (1, 'Hitam'),
    (2, 'Putih'),
    (3, 'Merah');

INSERT INTO ukuran (id, nama) VALUES
    (1, 'S'),
    (2, 'M'),
    (3, 'L');

SELECT * FROM warna ORDER BY id;
SELECT * FROM ukuran ORDER BY id;

cross-dasar.sql

Unduh berkas
sql
-- CROSS JOIN: setiap warna dipasangkan dengan setiap ukuran
SELECT w.nama AS warna, u.nama AS ukuran
FROM warna w
CROSS JOIN ukuran u
ORDER BY w.id, u.id;

-- Jumlah baris hasil = jumlah baris warna x jumlah baris ukuran
SELECT count(*) AS jumlah_kombinasi
FROM warna w
CROSS JOIN ukuran u;

cross-filter.sql

Unduh berkas
sql
-- CROSS JOIN lalu disaring: kombinasi yang masuk katalog produksi
SELECT w.nama AS warna, u.nama AS ukuran
FROM warna w
CROSS JOIN ukuran u
WHERE w.nama <> 'Merah' AND u.nama <> 'S'
ORDER BY w.id, u.id;

jadwal-generate-series.sql

Unduh berkas
sql
-- Membangkitkan jadwal shift gudang: 3 hari x 3 jam mulai = 9 baris
SELECT d.hari::date AS tanggal, j.jam AS jam_mulai
FROM generate_series(DATE '2026-03-02', DATE '2026-03-04', INTERVAL '1 day') AS d(hari)
CROSS JOIN generate_series(9, 11) AS j(jam)
ORDER BY d.hari, j.jam;

join-tanpa-on.sql

Unduh berkas
sql
-- PostgreSQL menolak JOIN tanpa ON atau USING: bukan diam-diam jadi cross join
SELECT p.nama, ps.id AS pesanan
FROM pelanggan p
JOIN pesanan ps;