“Tampilkan pesanan TERBARU dari setiap pelanggan.” Laporan seperti itu muncul di hampir semua aplikasi: transaksi terakhir per akun, log terbaru per server, produk terlaris per kategori. Masalahnya, pola ini butuh subquery yang berperilaku berbeda untuk setiap baris pelanggan — sesuatu yang tidak bisa dilakukan join biasa maupun subquery FROM biasa, karena subquery di FROM dievaluasi hanya sekali untuk seluruh query. PostgreSQL membuka pintu itu dengan kata kunci LATERAL: penanda bahwa sebuah subquery di FROM boleh membaca kolom dari tabel-tabel yang ditulis SEBELUM dirinya, sehingga isinya dihitung ulang untuk setiap baris. Chapter ini membangun pola top-N per kelompok langkah demi langkah, menunjukkan error persis yang muncul bila LATERAL dilupakan, lalu memperkenalkan kerabatnya yang tidak perlu kata kunci: set-returning function seperti generate_series dan unnest di klausa FROM.
Masalahnya: Subquery di FROM Tidak Melihat Baris Tetangganya #
Skema contoh kembali memakai domain kepercayaan section ini — empat pelanggan, lima pesanan, Andi Wijaya tanpa satupun pesanan:
-- Skema contoh: pelanggan 4 baris, pesanan 5 baris
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);
SELECT * FROM pelanggan ORDER BY id;
SELECT * FROM pesanan ORDER BY id;Output yang Dihasilkan:
CREATE TABLE
CREATE TABLE
INSERT 0 4
INSERT 0 5
id | nama | kota | email
----+--------------+----------+------------------
1 | Budi Santoso | Bandung | [email protected]
2 | Siti Rahayu | Jakarta | [email protected]
3 | Andi Wijaya | Surabaya | [email protected]
4 | Dewi Lestari | Medan | [email protected]
(4 rows)
id | pelanggan_id | tanggal | total
-----+--------------+------------+-----------
101 | 1 | 2026-01-05 | 150000.00
102 | 1 | 2026-01-18 | 75000.00
103 | 2 | 2026-01-09 | 220000.00
104 | 4 | 2026-02-02 | 50000.00
105 | | 2026-02-10 | 30000.00
(5 rows)Percobaan pertama yang wajar adalah memakai subquery LIMIT 1 di FROM — ambil “pesanan terbaru”, lalu gabungkan:
-- Percobaan pertama: subquery LIMIT 1 di FROM
-- Subquery ini mengembalikan SATU baris untuk seluruh query: pesanan terbaru se-toko
SELECT id, pelanggan_id, tanggal
FROM pesanan
ORDER BY tanggal DESC, id DESC
LIMIT 1;
-- Digabungkan ke pelanggan: 0 baris, karena pesanan 105 adalah walk-in tanpa pelanggan
SELECT p.nama, s.id AS pesanan, s.tanggal
FROM pelanggan p
JOIN (SELECT id, pelanggan_id, tanggal
FROM pesanan
ORDER BY tanggal DESC, id DESC
LIMIT 1) s ON s.pelanggan_id = p.id;Output yang Dihasilkan:
id | pelanggan_id | tanggal
-----+--------------+------------
105 | | 2026-02-10
(1 row)
nama | pesanan | tanggal
------+---------+---------
(0 rows)Hasilnya jebakan murni: subquery mengembalikan pesanan 105 (10 Februari, paling baru se-toko), dan karena 105 adalah transaksi walk-in tanpa pelanggan, join-nya menghasilkan NOL baris. Akar masalahnya bukan kebetulan data — subquery di FROM dievaluasi sekali untuk seluruh query, jadi LIMIT 1 di dalamnya memilih satu pemenang global, bukan satu pemenang per pelanggan. Agar “terbaru” dihitung per pelanggan, subquery harus tahu sedang memproses pelanggan yang mana — dan di situlah percobaan kedua gagal:
-- Subquery di FROM mencoba membaca p.id milik tabel di kirinya: ditolak
SELECT p.nama, s.id AS pesanan, s.tanggal
FROM pelanggan p
JOIN (SELECT id, pelanggan_id, tanggal
FROM pesanan
WHERE pelanggan_id = p.id
ORDER BY tanggal DESC, id DESC
LIMIT 1) s ON true;Output yang Dihasilkan:
psql:tanpa-lateral.sql:8: ERROR: invalid reference to FROM-clause entry for table "p"
LINE 5: WHERE pelanggan_id = p.id
^
DETAIL: There is an entry for table "p", but it cannot be referenced from this part of the query.
HINT: To reference that table, you must mark this subquery with LATERAL.PostgreSQL menolak dengan invalid reference to FROM-clause entry for table "p" — dan dengan HINT yang luar biasa membantu: To reference that table, you must mark this subquery with LATERAL. Aturannya tegas: secara bawaan, subquery di FROM hidup di ruang tertutup dan tidak boleh melihat tabel lain di level query yang sama. Kata kunci LATERAL adalah izin eksplisit untuk membuka ruang itu.
LATERAL: Subquery yang Dievaluasi per Baris #
Menambahkan satu kata menyelesaikan semuanya:
-- LATERAL: subquery dievaluasi ulang untuk SETIAP baris pelanggan
SELECT p.id, p.nama, s.id AS pesanan, s.tanggal, s.total
FROM pelanggan p
JOIN LATERAL (
SELECT ps.id, ps.tanggal, ps.total
FROM pesanan ps
WHERE ps.pelanggan_id = p.id
ORDER BY ps.tanggal DESC, ps.id DESC
LIMIT 1
) s ON true
ORDER BY p.id;
-- LEFT JOIN LATERAL: pelanggan tanpa pesanan tetap tampil
SELECT p.id, p.nama, s.id AS pesanan, s.tanggal, s.total
FROM pelanggan p
LEFT JOIN LATERAL (
SELECT ps.id, ps.tanggal, ps.total
FROM pesanan ps
WHERE ps.pelanggan_id = p.id
ORDER BY ps.tanggal DESC, ps.id DESC
LIMIT 1
) s ON true
ORDER BY p.id;Output yang Dihasilkan:
id | nama | pesanan | tanggal | total
----+--------------+---------+------------+-----------
1 | Budi Santoso | 102 | 2026-01-18 | 75000.00
2 | Siti Rahayu | 103 | 2026-01-09 | 220000.00
4 | Dewi Lestari | 104 | 2026-02-02 | 50000.00
(3 rows)
id | nama | pesanan | tanggal | total
----+--------------+---------+------------+-----------
1 | Budi Santoso | 102 | 2026-01-18 | 75000.00
2 | Siti Rahayu | 103 | 2026-01-09 | 220000.00
3 | Andi Wijaya | | |
4 | Dewi Lestari | 104 | 2026-02-02 | 50000.00
(4 rows)Query pertama mengembalikan tiga baris — pesanan terbaru Budi (102), Siti (103), dan Dewi (104); query kedua menambahkan Andi Wijaya dengan kolom pesanan tertambal NULL. Mari kita bedah komponennya:
JOIN LATERAL ( ... ) s— subquery ditandaiLATERALsehingga boleh membacap.id. Perhatikan bahwa semua logika pemilihan (WHERE,ORDER BY,LIMIT) tinggal di dalam subquery; join di luar hanya menerima hasilnya.WHERE ps.pelanggan_id = p.id— kondisi korelasi: inilah baris yang ditolak PostgreSQL sebelumLATERALditambahkan. Untuk baris Budi, subquery mencari pesanan pemilik id 1; untuk baris Siti, pemilik id 2 — satu subquery, dievaluasi ulang per baris pelanggan.ORDER BY ps.tanggal DESC, ps.id DESC LIMIT 1— pesanan pelanggan diurutkan dari yang terbaru, lalu diambil satu. Tie-breakps.id DESCmenjaga determinisme bila dua pesanan jatuh di tanggal sama.ON true— idiom baku untuk LATERAL: kondisi penggabungan sudah selesai di dalam subquery (yang menghasilkan nol atau satu baris per pelanggan), jadi klausaONcukup menerima apa adanya.LEFT JOIN LATERALpada query kedua — pelanggan tanpa pesanan tetap tampil dengan NULL, persis perilaku LEFT JOIN dari chapter LEFT JOIN ; bila tujuan laporan adalah “semua pelanggan beserta transaksi terakhirnya”, bentuk inilah yang benar.
Diagram ini model mental LATERAL: tabel sumber mengalir baris demi baris, subquery menyala sekali per baris dengan nilai p.id yang sedang aktif, dan hasilnya menempel sebagai kolom tambahan. Pola ini bernama top-N per group dan menjadi senjata utama LATERAL — ganti pelanggan/pesanan dengan kategori/produk atau server/log, strukturnya tidak berubah. Subquery juga tidak terbatas LIMIT 1: ambil tiga pesanan terakhir dengan LIMIT 3, atau nilai agregat per pelanggan dengan GROUP BY di dalamnya.
LATERAL untuk Set-Returning Function #
Kerabat dekat LATERAL adalah function penghasil baris (set-returning function) seperti generate_series dan unnest. Untuk pemanggilan function di FROM, kata LATERAL boleh ditulis tapi tidak wajib — PostgreSQL memperlakukannya sebagai implicitly lateral:
-- generate_series per baris: jumlah cicilan bergantung pada total tiap pesanan
SELECT ps.id AS pesanan, g.i AS cicilan_ke,
(ps.tanggal + g.i * INTERVAL '1 month')::date AS jatuh_tempo
FROM pesanan ps
CROSS JOIN LATERAL generate_series(
1,
CASE WHEN ps.total >= 150000.00 THEN 3 ELSE 1 END
) AS g(i)
ORDER BY ps.id, g.i;
-- Tanpa kata LATERAL: hasil sama, karena pemanggilan fungsi di FROM implicitly lateral
SELECT ps.id AS pesanan, g.i AS cicilan_ke
FROM pesanan ps
CROSS JOIN generate_series(
1,
CASE WHEN ps.total >= 150000.00 THEN 3 ELSE 1 END
) AS g(i)
ORDER BY ps.id, g.i;
-- unnest di FROM juga implicitly lateral: array tag diurai per baris produk
SELECT pr.nama, t.tag
FROM (VALUES ('Kaos Hitam', ARRAY['pakaian', 'unisex']),
('Mug Keramik', ARRAY['dapur', 'souvenir'])) AS pr(nama, tag)
CROSS JOIN unnest(pr.tag) AS t(tag)
ORDER BY pr.nama, t.tag;Output yang Dihasilkan:
pesanan | cicilan_ke | jatuh_tempo
---------+------------+-------------
101 | 1 | 2026-02-05
101 | 2 | 2026-03-05
101 | 3 | 2026-04-05
102 | 1 | 2026-02-18
103 | 1 | 2026-02-09
103 | 2 | 2026-03-09
103 | 3 | 2026-04-09
104 | 1 | 2026-03-02
105 | 1 | 2026-03-10
(9 rows)
pesanan | cicilan_ke
---------+------------
101 | 1
101 | 2
101 | 3
102 | 1
103 | 1
103 | 2
103 | 3
104 | 1
105 | 1
(9 rows)
nama | tag
-------------+----------
Kaos Hitam | pakaian
Kaos Hitam | unisex
Mug Keramik | dapur
Mug Keramik | souvenir
(4 rows)Query pertama membangkitkan skema cicilan: pesanan bervolume 150000 ke atas dipecah tiga cicilan bulanan, sisanya sekali bayar — sembilan baris dari lima pesanan. Batas atas generate_series(1, ...) dihitung dari ps.total, kolom milik tabel pesanan yang berdiri di kirinya; itulah kerja sama lateral yang sama, hanya kini di dalam argumen function. Query kedua sengaja menghapus kata LATERAL dan hasilnya identik sembilan baris — bukti hidup bahwa pemanggilan function di FROM tidak perlu penanda. Kontrasnya tajam dengan subquery: generate_series(...) boleh membaca ps.total tanpa izin, tetapi (SELECT ... WHERE pelanggan_id = p.id) tanpa LATERAL ditolak keras, seperti error di awal chapter.
Query ketiga memakai unnest untuk mengurai array tag menjadi baris — dua tag Kaos Hitam menjadi dua baris, dua tag Mug Keramik menjadi dua baris lainnya. Pola explode-array-ini-row ini adalah pemakaian unnest paling umum di FROM, dan perilakunya yang bisa membaca kolom pr.tag dari sisi kiri persis karena sifat implicitly lateral yang sama. Tipe array text[] sendiri dibahas tuntas di section Arrays dan Custom Types
.
LATERAL versus Window Function #
Pola top-N per group punya pesaing mapan: window function. Kedua-duanya menghasilkan jawaban yang benar, dengan karakter berbeda:
-- Solusi window function: row_number memberi nomor urut per pelanggan
SELECT p.id, p.nama, t.id AS pesanan, t.tanggal, t.total
FROM pelanggan p
LEFT JOIN (
SELECT id, pelanggan_id, tanggal, total,
row_number() OVER (
PARTITION BY pelanggan_id
ORDER BY tanggal DESC, id DESC
) AS rn
FROM pesanan
) t ON t.pelanggan_id = p.id AND t.rn = 1
ORDER BY p.id;Output yang Dihasilkan:
id | nama | pesanan | tanggal | total
----+--------------+---------+------------+-----------
1 | Budi Santoso | 102 | 2026-01-18 | 75000.00
2 | Siti Rahayu | 103 | 2026-01-09 | 220000.00
3 | Andi Wijaya | | |
4 | Dewi Lestari | 104 | 2026-02-02 | 50000.00
(4 rows)Empat baris yang sama, termasuk Andi yang tertambal NULL. Versi window function memberi nomor urut (row_number) pada setiap pesanan di dalam partisinya masing-masing pelanggan, lalu join hanya mengambil yang bernomor 1 — seluruh mekanisme window function ini dibahas tuntas di section Window Functions
. Perbandingan keduanya:
| Aspek | LEFT JOIN LATERAL ... LIMIT 1 | row_number() ... rn = 1 |
|---|---|---|
| Struktur | logika per-baris terpusat di satu subquery | dua langkah: hitung nomor, lalu saring |
| Mengambil N teratas | ganti LIMIT 1 menjadi LIMIT 3 | ganti rn = 1 menjadi rn <= 3 |
| Butuh data agregat lain per pelanggan | tinggal tambah kolom di subquery LATERAL | harus kembali ke GROUP BY terpisah |
| Ukuran hasil per baris induk | bebas (0, 1, atau N baris) | ditentukan ulang oleh filter rn |
| Dipelajari | di chapter ini | section Window Functions |
LATERAL menutup galeri join yang bekerja keras menentukan pasangan secara eksplisit. Satu varian terakhir bergerak ke arah sebaliknya: menyerahkan seluruh keputusan kolom penggabungan kepada database lewat NATURAL JOIN — kenyamanan yang tampak menggoda sampai kita membedah harganya di chapter berikutnya.
Referensi #
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
buat-tabel.sql
Unduh berkas-- Skema contoh: pelanggan 4 baris, pesanan 5 baris
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);
SELECT * FROM pelanggan ORDER BY id;
SELECT * FROM pesanan ORDER BY id;
lateral-dasar.sql
Unduh berkas-- LATERAL: subquery dievaluasi ulang untuk SETIAP baris pelanggan
SELECT p.id, p.nama, s.id AS pesanan, s.tanggal, s.total
FROM pelanggan p
JOIN LATERAL (
SELECT ps.id, ps.tanggal, ps.total
FROM pesanan ps
WHERE ps.pelanggan_id = p.id
ORDER BY ps.tanggal DESC, ps.id DESC
LIMIT 1
) s ON true
ORDER BY p.id;
-- LEFT JOIN LATERAL: pelanggan tanpa pesanan tetap tampil
SELECT p.id, p.nama, s.id AS pesanan, s.tanggal, s.total
FROM pelanggan p
LEFT JOIN LATERAL (
SELECT ps.id, ps.tanggal, ps.total
FROM pesanan ps
WHERE ps.pelanggan_id = p.id
ORDER BY ps.tanggal DESC, ps.id DESC
LIMIT 1
) s ON true
ORDER BY p.id;
lateral-srf.sql
Unduh berkas-- generate_series per baris: jumlah cicilan bergantung pada total tiap pesanan
SELECT ps.id AS pesanan, g.i AS cicilan_ke,
(ps.tanggal + g.i * INTERVAL '1 month')::date AS jatuh_tempo
FROM pesanan ps
CROSS JOIN LATERAL generate_series(
1,
CASE WHEN ps.total >= 150000.00 THEN 3 ELSE 1 END
) AS g(i)
ORDER BY ps.id, g.i;
-- Tanpa kata LATERAL: hasil sama, karena pemanggilan fungsi di FROM implicitly lateral
SELECT ps.id AS pesanan, g.i AS cicilan_ke
FROM pesanan ps
CROSS JOIN generate_series(
1,
CASE WHEN ps.total >= 150000.00 THEN 3 ELSE 1 END
) AS g(i)
ORDER BY ps.id, g.i;
-- unnest di FROM juga implicitly lateral: array tag diurai per baris produk
SELECT pr.nama, t.tag
FROM (VALUES ('Kaos Hitam', ARRAY['pakaian', 'unisex']),
('Mug Keramik', ARRAY['dapur', 'souvenir'])) AS pr(nama, tag)
CROSS JOIN unnest(pr.tag) AS t(tag)
ORDER BY pr.nama, t.tag;
lateral-vs-window.sql
Unduh berkas-- Solusi window function: row_number memberi nomor urut per pelanggan
SELECT p.id, p.nama, t.id AS pesanan, t.tanggal, t.total
FROM pelanggan p
LEFT JOIN (
SELECT id, pelanggan_id, tanggal, total,
row_number() OVER (
PARTITION BY pelanggan_id
ORDER BY tanggal DESC, id DESC
) AS rn
FROM pesanan
) t ON t.pelanggan_id = p.id AND t.rn = 1
ORDER BY p.id;
subquery-global.sql
Unduh berkas-- Percobaan pertama: subquery LIMIT 1 di FROM
-- Subquery ini mengembalikan SATU baris untuk seluruh query: pesanan terbaru se-toko
SELECT id, pelanggan_id, tanggal
FROM pesanan
ORDER BY tanggal DESC, id DESC
LIMIT 1;
-- Digabungkan ke pelanggan: 0 baris, karena pesanan 105 adalah walk-in tanpa pelanggan
SELECT p.nama, s.id AS pesanan, s.tanggal
FROM pelanggan p
JOIN (SELECT id, pelanggan_id, tanggal
FROM pesanan
ORDER BY tanggal DESC, id DESC
LIMIT 1) s ON s.pelanggan_id = p.id;
tanpa-lateral.sql
Unduh berkas-- Subquery di FROM mencoba membaca p.id milik tabel di kirinya: ditolak
SELECT p.nama, s.id AS pesanan, s.tanggal
FROM pelanggan p
JOIN (SELECT id, pelanggan_id, tanggal
FROM pesanan
WHERE pelanggan_id = p.id
ORDER BY tanggal DESC, id DESC
LIMIT 1) s ON true;