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:

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

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

  1. nama_lengkap text GENERATED ALWAYS AS (...) STORED: kolom ini tidak pernah menerima nilai dari luar. Kata ALWAYS menegaskan bahwa nilainya selalu dihitung dari ekspresi, tanpa pengecualian.
  2. (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.
  3. INSERT hanya mengisi tiga kolom: nama_lengkap sengaja tidak disebut — dan tidak boleh disebut, seperti akan kita buktikan nanti.
  4. Hasil SELECT: kedua baris menampilkan Budi Santoso dan Siti Rahayu meskipun 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:

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

text
psql:tidak-immutable.sql:5: ERROR:  generation expression is not immutable

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

sql harga-pajak.sql
-- 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:

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

Output yang Dihasilkan:

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

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

AspekGenerated columnView / hitung di queryHitung di aplikasi
Tempat perhitunganserver, saat INSERT/UPDATEserver, saat SELECTclient, sebelum tulis
Penyimpanan ekstraya (disimpan di disk)tidaktidak
Bisa di-index / di-JOIN cepatya, seperti kolom biasaya, bila materializedtidak
Dijamin konsisten dengan kolom sumberya, oleh serverya, selalu segartidak, rawan drift
Cocok untuknilai turunan yang sering dibacanilai yang rumit/bergantung non-immutablelogika 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
sql
-- 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
sql
-- 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
sql
-- 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
sql
-- 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
sql
-- 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
sql
-- 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;