“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:

sql buat-tabel.sql
-- 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:

text
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:

text
 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:

text
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:

text
 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:

  1. JOIN LATERAL ( ... ) s — subquery ditandai LATERAL sehingga boleh membaca p.id. Perhatikan bahwa semua logika pemilihan (WHERE, ORDER BY, LIMIT) tinggal di dalam subquery; join di luar hanya menerima hasilnya.
  2. WHERE ps.pelanggan_id = p.id — kondisi korelasi: inilah baris yang ditolak PostgreSQL sebelum LATERAL ditambahkan. Untuk baris Budi, subquery mencari pesanan pemilik id 1; untuk baris Siti, pemilik id 2 — satu subquery, dievaluasi ulang per baris pelanggan.
  3. ORDER BY ps.tanggal DESC, ps.id DESC LIMIT 1 — pesanan pelanggan diurutkan dari yang terbaru, lalu diambil satu. Tie-break ps.id DESC menjaga determinisme bila dua pesanan jatuh di tanggal sama.
  4. ON true — idiom baku untuk LATERAL: kondisi penggabungan sudah selesai di dalam subquery (yang menghasilkan nol atau satu baris per pelanggan), jadi klausa ON cukup menerima apa adanya.
  5. LEFT JOIN LATERAL pada 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:

sql lateral-srf.sql
-- 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:

text
 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:

text
 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:

AspekLEFT JOIN LATERAL ... LIMIT 1row_number() ... rn = 1
Strukturlogika per-baris terpusat di satu subquerydua langkah: hitung nomor, lalu saring
Mengambil N teratasganti LIMIT 1 menjadi LIMIT 3ganti rn = 1 menjadi rn <= 3
Butuh data agregat lain per pelanggantinggal tambah kolom di subquery LATERALharus kembali ke GROUP BY terpisah
Ukuran hasil per baris indukbebas (0, 1, atau N baris)ditentukan ulang oleh filter rn
Dipelajaridi chapter inisection 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
sql
-- 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
sql
-- 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
sql
-- 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
sql
-- 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
sql
-- 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
sql
-- 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;