Data turunan ada di mana-mana: nama lengkap adalah gabungan nama depan dan belakang, harga jual adalah harga dasar ditambah pajak, email ternormalisasi adalah email yang di-lowercase. Selama nilai turunan itu dihitung di aplikasi, tabel hanya bisa percaya bahwa aplikasinya benar — dan begitu satu jalur kode lupa mengalikan pajak, data di database menjadi saling bertentangan. PostgreSQL menawarkan jalan keluar yang menyelesaikan masalahnya di tempat data disimpan: generated column adalah kolom yang nilainya selalu dihitung server dari ekspresi atas kolom-kolom lain di baris yang sama, otomatis saat INSERT maupun UPDATE. Pada chapter ini kita membangun tiga contoh — nama lengkap, harga dengan pajak, dan email ternormalisasi — sembari mempelajari batasan ketatnya: ekspresi harus immutable dan kolomnya tidak boleh ditulis langsung.
Konsep dan Sintaks Dasar #
Deklarasi generated column menempel pada definisi kolom dengan pola nama_kolom tipe GENERATED ALWAYS AS (ekspresi) STORED. Kata STORED wajib di PostgreSQL: hasil ekspresi benar-benar disimpan di disk, bukan dihitung ulang setiap kali dibaca. Kita mulai dari tabel pegawai yang menautkan dua kolom nama menjadi satu nama lengkap:
-- nama_lengkap selalu dihitung server dari dua kolom sumbernya
CREATE TABLE pegawai (
id integer PRIMARY KEY,
nama_depan text NOT NULL,
nama_belakang text NOT NULL,
nama_lengkap text GENERATED ALWAYS AS (nama_depan || ' ' || nama_belakang) STORED
);
INSERT INTO pegawai (id, nama_depan, nama_belakang) VALUES
(1, 'Budi', 'Santoso'),
(2, 'Siti', 'Rahayu');
SELECT id, nama_depan, nama_belakang, nama_lengkap
FROM pegawai
ORDER BY id;Output yang Dihasilkan:
CREATE TABLE
INSERT 0 2
id | nama_depan | nama_belakang | nama_lengkap
----+------------+---------------+--------------
1 | Budi | Santoso | Budi Santoso
2 | Siti | Rahayu | Siti Rahayu
(2 rows)Mari kita bedah deklarasinya:
nama_lengkap text GENERATED ALWAYS AS (...) STORED: kolom ini tidak pernah menerima nilai dari luar. KataALWAYSmenegaskan bahwa nilainya selalu dihitung dari ekspresi, tanpa pengecualian.(nama_depan || ' ' || nama_belakang): ekspresi penggabung teks dengan operator||— spasi di antara dua nama menjadi bagian dari ekspresi. Ekspresi hanya boleh membaca kolom baris yang sama, bukan baris lain dan bukan tabel lain.INSERThanya mengisi tiga kolom:nama_lengkapsengaja tidak disebut — dan tidak boleh disebut, seperti akan kita buktikan nanti.- Hasil
SELECT: kedua baris menampilkanBudi SantosodanSiti Rahayumeskipun nilai itu tidak pernah ditulis siapa pun.
Karena nilainya dihitung ulang server, kolom generated tidak pernah basi. Ketika kolom sumber berubah, kolom turunannya berubah pada saat yang sama:
-- Kolom generated ikut berubah saat kolom sumbernya di-UPDATE
UPDATE pegawai SET nama_belakang = 'Santoso Putra' WHERE id = 1;
SELECT id, nama_lengkap
FROM pegawai
ORDER BY id;Output yang Dihasilkan:
UPDATE 1
id | nama_lengkap
----+--------------------
1 | Budi Santoso Putra
2 | Siti Rahayu
(2 rows)UPDATE hanya menyentuh nama_belakang, tetapi hasil SELECT menunjukkan nama_lengkap Budi kini berbunyi Budi Santoso Putra. Tidak ada trigger, tidak ada kode aplikasi, tidak ada jendela waktu ketika kedua kolom tidak sinkron — sifat inilah yang membedakan generated column dari sekadar kolom biasa yang diisi aplikasi secara manual.
Ekspresi Harus Immutable #
Server menuntut satu syarat penting: ekspresi generated column harus immutable — memberikan hasil yang sama untuk input yang sama, selamanya. Alasannya teknis namun nyata: nilai kolom disimpan dan hanya dihitung ulang saat barisnya berubah. Bila ekspresinya bergantung pada sesuatu yang berubah sendiri, nilai tersimpan akan salah dan tetap salah tanpa pernah diperbaiki. Fungsi random() jelas melanggar syarat itu:
-- random() tidak immutable: hasilnya berbeda setiap dipanggil
CREATE TABLE tes_token (
id integer PRIMARY KEY,
token numeric GENERATED ALWAYS AS (random()) STORED
);Output yang Dihasilkan:
psql:tidak-immutable.sql:5: ERROR: generation expression is not immutableServer menolak tabel itu bahkan sebelum satu baris pun masuk: generation expression is not immutable. Penolakan yang sama menimpa fungsi-fungsi waktu seperti now() dan current_timestamp — hasilnya berubah seiring jam berjalan, sehingga tidak boleh menjadi dasar perhitungan kolom. Pola waktu pembuatan seperti DEFAULT now() dari chapter Default Value tetap sah karena DEFAULT hanya mengevaluasi sekali saat baris dibuat; generated column berbeda karena nilainya bisa dihitung ulang kapan saja dan harus selalu bisa direproduksi dari isi barisnya sendiri.
Tiga Pola yang Sering Dipakai #
Tiga use case paling umum generated column: agregasi nilai dalam satu baris (harga ditambah pajak), normalisasi format (email), dan penggabungan kolom teks. Tabel produk berikut menghitung harga dengan PPN 11% langsung di definisi tabel:
-- Harga jual dengan PPN 11% tidak perlu dihitung aplikasi lagi
CREATE TABLE produk (
kode text PRIMARY KEY,
nama text NOT NULL,
harga numeric(10, 2) NOT NULL,
harga_dengan_pajak numeric(10, 2) GENERATED ALWAYS AS (harga * 1.11) STORED
);
INSERT INTO produk (kode, nama, harga) VALUES
('B001', 'Buku Tulis', 4500.00),
('P001', 'Pulpen Hitam', 3000.00),
('K001', 'Kertas HVS A4', 55000.00);
SELECT kode, nama, harga, harga_dengan_pajak
FROM produk
ORDER BY kode;Output yang Dihasilkan:
CREATE TABLE
INSERT 0 3
kode | nama | harga | harga_dengan_pajak
------+---------------+----------+--------------------
B001 | Buku Tulis | 4500.00 | 4995.00
K001 | Kertas HVS A4 | 55000.00 | 61050.00
P001 | Pulpen Hitam | 3000.00 | 3330.00
(3 rows)Perhitungan 4500.00 * 1.11 = 4995.00 tersimpan sebagai bagian dari tabel, bukan sebagai tanggung jawab tiap program yang menulis ke produk. Bila tarif pajak berubah suatu hari, ekspresinya diganti lewat ALTER TABLE ... ALTER COLUMN — satu perubahan di satu tempat, dan seluruh baris dihitung ulang dengan tarif baru. Pola kedua adalah normalisasi: menyimpan bentuk asli email untuk ditampilkan, sambil menyediakan bentuk baku untuk pencarian dan keunikan:
-- Kolom email ternormalisasi: selalu lowercase, bebas salah ketik format
CREATE TABLE kontak (
id integer PRIMARY KEY,
email text NOT NULL,
email_baku text GENERATED ALWAYS AS (lower(email)) STORED
);
INSERT INTO kontak (id, email) VALUES
(1, '[email protected]'),
(2, '[email protected]');
SELECT id, email, email_baku
FROM kontak
ORDER BY id;
\d kontakOutput yang Dihasilkan:
CREATE TABLE
INSERT 0 2
id | email | email_baku
----+------------------+------------------
1 | [email protected] | [email protected]
2 | [email protected] | [email protected]
(2 rows)
Table "public.kontak"
Column | Type | Collation | Nullable | Default
------------+---------+-----------+----------+-------------------------------------------
id | integer | | not null |
email | text | | not null |
email_baku | text | | | generated always as (lower(email)) stored
Indexes:
"kontak_pkey" PRIMARY KEY, btree (id)Kolom email menyimpan teks persis seperti yang diketik pengguna, sedangkan email_baku selalu berisi versi lowercase-nya. Tampilan pada hasil \d menegaskan status kolomnya: generated always as (lower(email)) stored tercantum pada kolom Default. Query dan constraint tinggal memakai email_baku tanpa perlu mengulang lower(email) di mana-mana — dan karena fungsinya lower() immutable, syarat perhitungan ulang terpenuhi.
Tidak Boleh Ditulis Langsung #
Kolom generated bukan kolom yang “diisi otomatis bila kosong” — nilainya memang bukan milik kita untuk ditentukan. Mencoba menyisipkan nilai ke dalamnya langsung ditolak server:
-- Kolom generated tidak boleh diisi nilai buatan sendiri
INSERT INTO pegawai (id, nama_depan, nama_belakang, nama_lengkap)
VALUES (3, 'Tono', 'Sugiarto', 'Tono Sugiarto');Output yang Dihasilkan:
psql:insert-generated.sql:3: ERROR: cannot insert a non-DEFAULT value into column "nama_lengkap"
DETAIL: Column "nama_lengkap" is a generated column.Error tersebut spesifik: nilai non-DEFAULT tidak boleh masuk ke nama_lengkap karena kolom itu generated. Perilaku yang sama berlaku untuk UPDATE — perintah UPDATE pegawai SET nama_lengkap = ... ditolak dengan pesan bahwa kolom itu hanya boleh diubah menjadi DEFAULT. Aplikasi tidak punya jalan memalsukan nama lengkap yang tidak sesuai komponennya; konsistensi antar kolom dijamin oleh definisi tabel.
STORED, Virtual, dan Alternatifnya #
PostgreSQL 17 hanya mengenal generated column tipe STORED — dihitung saat tulis, disimpan di disk, dan ikut memakan ruang penyimpanan. Standar SQL juga mendefinisikan tipe VIRTUAL — tidak disimpan, dihitung setiap kali dibaca — yang di PostgreSQL belum tersedia hingga versi 17 (MySQL mendukungnya, sebagai pembanding). Sebelum mengandalkan generated column, bandingkan dulu dengan dua alternatif perhitungan di sisi baca:
| Aspek | Generated column | View / hitung di query | Hitung di aplikasi |
|---|---|---|---|
| Tempat perhitungan | server, saat INSERT/UPDATE | server, saat SELECT | client, sebelum tulis |
| Penyimpanan ekstra | ya (disimpan di disk) | tidak | tidak |
| Bisa di-index / di-JOIN cepat | ya, seperti kolom biasa | ya, bila materialized | tidak |
| Dijamin konsisten dengan kolom sumber | ya, oleh server | ya, selalu segar | tidak, rawan drift |
| Cocok untuk | nilai turunan yang sering dibaca | nilai yang rumit/bergantung non-immutable | logika yang butuh data luar |
Aturan praktisnya sederhana: nilai turunan yang bersumber dari baris yang sama, ekspresinya immutable, dan dibaca sering — itulah kandidat generated column. Nilai yang bergantung waktu, data tabel lain, atau parameter luar tetap dihitung di query atau aplikasi.
Kita sekarang punya dua mekanisme pengisian otomatis: DEFAULT yang mengisi sekali saat baris dibuat, dan generated column yang menjaga nilai turunan selalu segar. Masih ada satu bentuk pengisian otomatis yang paling sering dipakai di dunia nyata: penomoran identitas baris — 1, 2, 3, ... — yang di PostgreSQL modern punya sintaks terstandar bernama identity column, topik chapter berikutnya.
Referensi #
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
buat-tabel.sql
Unduh berkas-- nama_lengkap selalu dihitung server dari dua kolom sumbernya
CREATE TABLE pegawai (
id integer PRIMARY KEY,
nama_depan text NOT NULL,
nama_belakang text NOT NULL,
nama_lengkap text GENERATED ALWAYS AS (nama_depan || ' ' || nama_belakang) STORED
);
INSERT INTO pegawai (id, nama_depan, nama_belakang) VALUES
(1, 'Budi', 'Santoso'),
(2, 'Siti', 'Rahayu');
SELECT id, nama_depan, nama_belakang, nama_lengkap
FROM pegawai
ORDER BY id;
harga-pajak.sql
Unduh berkas-- Harga jual dengan PPN 11% tidak perlu dihitung aplikasi lagi
CREATE TABLE produk (
kode text PRIMARY KEY,
nama text NOT NULL,
harga numeric(10, 2) NOT NULL,
harga_dengan_pajak numeric(10, 2) GENERATED ALWAYS AS (harga * 1.11) STORED
);
INSERT INTO produk (kode, nama, harga) VALUES
('B001', 'Buku Tulis', 4500.00),
('P001', 'Pulpen Hitam', 3000.00),
('K001', 'Kertas HVS A4', 55000.00);
SELECT kode, nama, harga, harga_dengan_pajak
FROM produk
ORDER BY kode;
insert-generated.sql
Unduh berkas-- Kolom generated tidak boleh diisi nilai buatan sendiri
INSERT INTO pegawai (id, nama_depan, nama_belakang, nama_lengkap)
VALUES (3, 'Tono', 'Sugiarto', 'Tono Sugiarto');
normalisasi-email.sql
Unduh berkas-- Kolom email ternormalisasi: selalu lowercase, bebas salah ketik format
CREATE TABLE kontak (
id integer PRIMARY KEY,
email text NOT NULL,
email_baku text GENERATED ALWAYS AS (lower(email)) STORED
);
INSERT INTO kontak (id, email) VALUES
(1, '[email protected]'),
(2, '[email protected]');
SELECT id, email, email_baku
FROM kontak
ORDER BY id;
\d kontak
tidak-immutable.sql
Unduh berkas-- random() tidak immutable: hasilnya berbeda setiap dipanggil
CREATE TABLE tes_token (
id integer PRIMARY KEY,
token numeric GENERATED ALWAYS AS (random()) STORED
);
update-propagasi.sql
Unduh berkas-- Kolom generated ikut berubah saat kolom sumbernya di-UPDATE
UPDATE pegawai SET nama_belakang = 'Santoso Putra' WHERE id = 1;
SELECT id, nama_lengkap
FROM pegawai
ORDER BY id;