Tiga constraint sebelumnya menjaga bentuk data: unik, terisi, dan merujuk dengan benar. Tetapi aturan bisnis nyata lebih kaya dari itu — harga tidak boleh negatif, tanggal selesai tidak boleh mendahului tanggal mulai, NIP wajib berawalan NIP-, rating hanya boleh 1 sampai 5. Aturan seperti ini sering hanya hidup di validasi aplikasi, padahal data bisa masuk lewat banyak pintu: script impor, tool admin, perintah manual di psql. Constraint CHECK menempelkan validasi itu langsung pada tabel: setiap baris baru dan setiap perubahan dievaluasi terhadap ekspresi yang kita tulis sendiri, dan yang bernilai tidak benar ditolak seketika. Chapter ini membedah idiom-idiomnya dari yang sederhana sampai batasannya, termasuk cara memasang CHECK pada tabel berisi data lama yang sudah melanggar.

CHECK Pertama: Validasi Nilai Sederhana #

Ekspresi CHECK adalah kondisi boolean yang harus benar (atau NULL — dibahas sebentar lagi) untuk setiap baris. Bentuk paling sederhana: perbandingan langsung pada satu kolom:

sql check-dasar.sql
-- CHECK pertama: tolak harga dan stok yang tidak masuk akal
CREATE TABLE barang (
    id    bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    nama  text NOT NULL,
    harga numeric(12, 2) NOT NULL,
    stok  integer NOT NULL DEFAULT 0,
    CONSTRAINT chk_harga_positif CHECK (harga >= 0),
    CONSTRAINT chk_stok_tak_negatif CHECK (stok >= 0)
);

INSERT INTO barang (nama, harga, stok) VALUES
    ('Kopi Arabika Gayo 250g', 85000.00, 40),
    ('Teh Melati Kotak 100g',   15000.00, 120);

SELECT id, nama, harga, stok FROM barang ORDER BY id;

\d barang

Output yang Dihasilkan:

text
CREATE TABLE
INSERT 0 2
 id |          nama          |  harga   | stok 
----+------------------------+----------+------
  1 | Kopi Arabika Gayo 250g | 85000.00 |   40
  2 | Teh Melati Kotak 100g  | 15000.00 |  120
(2 rows)

                            Table "public.barang"
 Column |     Type      | Collation | Nullable |           Default            
--------+---------------+-----------+----------+------------------------------
 id     | bigint        |           | not null | generated always as identity
 nama   | text          |           | not null | 
 harga  | numeric(12,2) |           | not null | 
 stok   | integer       |           | not null | 0
Indexes:
    "barang_pkey" PRIMARY KEY, btree (id)
Check constraints:
    "chk_harga_positif" CHECK (harga >= 0::numeric)
    "chk_stok_tak_negatif" CHECK (stok >= 0)

Dua hal penting dari definisi tersebut:

  1. Klausa CONSTRAINT chk_harga_positif memberi nama eksplisit pada constraint — nama itu muncul di output \d dan di pesan error. Tanpa klausa ini PostgreSQL mengarang nama default seperti barang_harga_check yang jauh kurang jelas.
  2. Output \d memperlihatkan kedua constraint pada bagian Check constraints:, lengkap dengan ekspresinya — dokumentasi hidup yang ikut terbawa bersama tabel.

Mencoba menyisipkan harga negatif ditolak dengan error yang menyebut nama constraint secara eksplisit:

INSERT INTO barang (nama, harga, stok)
VALUES ('Gula Kristal Putih 1kg', -16000.00, 50);

Output yang Dihasilkan:

text
psql:check-pelanggaran.sql:2: ERROR:  new row for relation "barang" violates check constraint "chk_harga_positif"
DETAIL:  Failing row contains (3, Gula Kristal Putih 1kg, -16000.00, 50).

Pesan new row for relation "barang" violates check constraint "chk_harga_positif" langsung menunjuk aturan mana yang dilanggar — inilah keuntungan menamai constraint dengan sadar.

Empat Idiom Ekspresi CHECK #

Ekspresi CHECK menerima hampir semua ekspresi boolean: pembanding, IN, BETWEEN, LIKE, sampai pemanggilan function. Tabel pegawai berikut memakai empat idiom sekaligus:

-- Empat idiom CHECK: perbandingan sudah, lalu IN, BETWEEN, LIKE, char_length
CREATE TABLE pegawai (
    id      bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    nip     text NOT NULL,
    nama    text NOT NULL,
    status  text NOT NULL,
    rating  integer,
    catatan text,
    CONSTRAINT chk_nip_format CHECK (nip LIKE 'NIP-%'),
    CONSTRAINT chk_status_daftar CHECK (status IN ('aktif', 'cuti', 'keluar')),
    CONSTRAINT chk_rating_rentang CHECK (rating BETWEEN 1 AND 5),
    CONSTRAINT chk_catatan_panjang CHECK (char_length(catatan) <= 200)
);

INSERT INTO pegawai (nip, nama, status, rating, catatan) VALUES
    ('NIP-0001', 'Budi Santoso', 'aktif', 4, 'Pelayanan prima'),
    ('NIP-0002', 'Siti Rahayu',  'cuti',  NULL, NULL);

SELECT id, nip, nama, status, rating, catatan FROM pegawai ORDER BY id;

Output yang Dihasilkan:

text
CREATE TABLE
INSERT 0 2
 id |   nip    |     nama     | status | rating |     catatan     
----+----------+--------------+--------+--------+-----------------
  1 | NIP-0001 | Budi Santoso | aktif  |      4 | Pelayanan prima
  2 | NIP-0002 | Siti Rahayu  | cuti   |        | 
(2 rows)

Empat constraint pada tabel tersebut mewakili empat pola yang menutup sebagian besar kebutuhan validasi:

  1. nip LIKE 'NIP-%': pola teks — NIP wajib berawalan NIP-; NIP yang formatnya menyimpang ditolak dengan pesan yang menyebut chk_nip_format.
  2. status IN ('aktif', 'cuti', 'keluar'): daftar nilai sah — typo seperti 'aktiff' atau status ciptaan sendiri langsung ditolak.
  3. rating BETWEEN 1 AND 5: rentang nilai — padat dan terbaca dibanding rating >= 1 AND rating <= 5.
  4. char_length(catatan) <= 200: function call — char_length() menghitung jumlah karakter, sehingga catatan yang melebihi 200 karakter gagal masuk.

Perhatikan juga baris kedua data: rating NULL lolos constraint BETWEEN 1 AND 5. Ekspresi NULL BETWEEN 1 AND 5 bernilai NULL, dan CHECK menganggap NULL sebagai lolos (bukan pelanggaran) — NULL berarti “belum ada nilai untuk divalidasi”. Bila rating wajib diisi dan tervalidasi, gabungkan NOT NULL dengan CHECK; keduanya saling melengkapi, bukan saling menggantikan.

CHECK Multi-Kolom: Membandingkan Dua Kolom Satu Baris #

Ekspresi CHECK boleh — dan sering perlu — membaca beberapa kolom sekaligus, karena aturan bisnis kerap menghubungkan nilai dalam baris yang sama. Kasus klasiknya rentang tanggal:

-- CHECK bisa membandingkan dua kolom dalam satu baris
CREATE TABLE proyek (
    id              bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    nama_proyek     text NOT NULL,
    tanggal_mulai   date NOT NULL,
    tanggal_selesai date,
    nilai_kontrak   numeric(14, 2) NOT NULL,
    CONSTRAINT chk_tanggal_berurutan CHECK (tanggal_selesai >= tanggal_mulai)
);

INSERT INTO proyek (nama_proyek, tanggal_mulai, tanggal_selesai, nilai_kontrak) VALUES
    ('Pembangunan Website', DATE '2026-01-10', DATE '2026-03-15', 250000000.00),
    ('Riset Pasar',         DATE '2026-02-01', NULL,                15000000.00);

SELECT id, nama_proyek, tanggal_mulai, tanggal_selesai, nilai_kontrak
FROM proyek
ORDER BY id;

Output yang Dihasilkan:

text
CREATE TABLE
INSERT 0 2
 id |     nama_proyek     | tanggal_mulai | tanggal_selesai | nilai_kontrak 
----+---------------------+---------------+-----------------+---------------
  1 | Pembangunan Website | 2026-01-10    | 2026-03-15      |  250000000.00
  2 | Riset Pasar         | 2026-02-01    |                 |   15000000.00
(2 rows)

Kedua baris diterima: yang pertama punya rentang tanggal sehat, dan proyek Riset Pasar yang belum selesai lolos karena NULL >= DATE '2026-02-01' bernilai NULL — sekali lagi NULL dianggap lolos. Rentang yang terbalik tidak seberuntung itu:

INSERT INTO proyek (nama_proyek, tanggal_mulai, tanggal_selesai, nilai_kontrak) VALUES
    ('Audit Stok', DATE '2026-04-01', DATE '2026-03-20', 8000000.00);

Output yang Dihasilkan:

text
psql:check-tanggal.sql:2: ERROR:  new row for relation "proyek" violates check constraint "chk_tanggal_berurutan"
DETAIL:  Failing row contains (3, Audit Stok, 2026-04-01, 2026-03-20, 8000000.00).

Proyek yang selesai tanggal 20 Maret padahal baru mulai 1 April ditolak oleh chk_tanggal_berurutan. Pola yang sama berlaku untuk pasangan kolom lain: diskon <= harga, tanggal_keluar >= tanggal_masuk, stok_minimum <= stok_maksimum — semua adalah aturan satu baris yang CHECK tangani dengan baik.

Batasan CHECK: Tidak Boleh Membaca Baris atau Tabel Lain #

Batas fundamental CHECK: ekspresinya hanya boleh melihat kolom-kolom baris yang sedang divalidasi — tidak boleh subquery, tidak boleh membaca tabel lain, tidak boleh membandingkan dengan baris lain di tabel yang sama. Percobaan berikut menempelkan batas kredit yang dibaca dari tabel lain:

-- CHECK tidak boleh membaca tabel lain: ditolak sejak statement CREATE TABLE
CREATE TABLE penjualan_importir (
    id     bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    jumlah numeric(12, 2) NOT NULL,
    CHECK (jumlah <= (SELECT max(plafon) FROM batasan_kredit))
);

Output yang Dihasilkan:

text
psql:check-subquery.sql:6: ERROR:  cannot use subquery in check constraint
LINE 4:     CHECK (jumlah <= (SELECT max(plafon) FROM batasan_kredit...
                             ^

PostgreSQL menolaknya sejak statement CREATE TABLE dengan pesan cannot use subquery in check constraint. Penolakan ini punya alasan desain yang dalam: CHECK harus bisa dievaluasi ulang kapan saja — saat INSERT, saat UPDATE pada kolom lain, bahkan saat baris dipulihkan dari backup — dan hasilnya harus tetap konsisten. Aturan lintas-baris atau lintas-tabel membuat hasil evaluasi bergantung pada data yang bisa berubah di luar baris itu, sehingga baris yang tadinya sah bisa “menjadi melanggar” tanpa disentuh. Untuk aturan seperti itu, alat yang tepat berbeda: trigger untuk validasi prosedural, atau pembandingan eksplisit lewat query aplikasi sebelum insert.

NOT VALID: Memasang CHECK pada Data Lama yang Melanggar #

Bagaimana bila aturan baru harus dipasang pada tabel yang sudah berisi data melanggar? ALTER TABLE ... ADD CONSTRAINT ... NOT VALID memasang constraint tanpa memeriksa baris lama — hanya baris baru dan baris yang diubah yang divalidasi seketika:

-- NOT VALID: pasang CHECK pada tabel berisi data lama yang melanggar
CREATE TABLE barang_lama (
    id    integer PRIMARY KEY,
    nama  text NOT NULL,
    harga numeric(12, 2)
);

-- Dua baris lama; yang pertama hasil bug zaman dahulu
INSERT INTO barang_lama (id, nama, harga) VALUES
    (1, 'Produk Rusak A', -5000.00),
    (2, 'Produk Baik B',  12000.00);

ALTER TABLE barang_lama
    ADD CONSTRAINT chk_harga_positif CHECK (harga >= 0) NOT VALID;

\d barang_lama

-- Perbaiki data lama, lalu validasikan constraint
UPDATE barang_lama SET harga = 5000.00 WHERE id = 1;

ALTER TABLE barang_lama VALIDATE CONSTRAINT chk_harga_positif;

\d barang_lama

Output yang Dihasilkan:

text
CREATE TABLE
INSERT 0 2
ALTER TABLE
               Table "public.barang_lama"
 Column |     Type      | Collation | Nullable | Default 
--------+---------------+-----------+----------+---------
 id     | integer       |           | not null | 
 nama   | text          |           | not null | 
 harga  | numeric(12,2) |           |          | 
Indexes:
    "barang_lama_pkey" PRIMARY KEY, btree (id)
Check constraints:
    "chk_harga_positif" CHECK (harga >= 0::numeric) NOT VALID

UPDATE 1
ALTER TABLE
               Table "public.barang_lama"
 Column |     Type      | Collation | Nullable | Default 
--------+---------------+-----------+----------+---------
 id     | integer       |           | not null | 
 nama   | text          |           | not null | 
 harga  | numeric(12,2) |           |          | 
Indexes:
    "barang_lama_pkey" PRIMARY KEY, btree (id)
Check constraints:
    "chk_harga_positif" CHECK (harga >= 0::numeric)

Output memperlihatkan siklus lengkapnya: \d pertama menampilkan chk_harga_positif ... NOT VALID — constraint aktif untuk data baru meski Produk Rusak A bernilai -5000 masih tersimpan. Setelah UPDATE memperbaiki baris lama, VALIDATE CONSTRAINT memeriksa seluruh tabel dan mengangkat tanda NOT VALID: \d kedua menampilkan constraint yang sama tanpa embel-embel. Alur ini adalah pola standar migrasi tabel produksi: pasang dengan NOT VALID (cepat, tanpa memindai tabel besar), perbaiki data bertahap, lalu VALIDATE saat semuanya bersih.

Konvensi Penamaan Constraint #

Sepanjang section ini kita melihat pola nama otomatis PostgreSQL (<tabel>_pkey, <tabel>_<kolom>_key, <tabel>_<kolom>_fkey) dan nama buatan tangan (chk_harga_positif). Untuk constraint yang kita namai sendiri, prefiks konsisten membuat pesan error dan query catalog jauh lebih terbaca:

PrefiksJenis constraintContoh nama
pk_primary keypk_barang
fk_foreign keyfk_pesanan_pelanggan
uq_uniqueuq_pelanggan_email
chk_checkchk_harga_positif

Prefiks membuat sejenis constraint terlihat sekali lihat — baik di pesan error, di output \d, maupun saat query pg_constraint. Kurs ini memakai prefiks chk_ pada semua contoh; pilih satu konvensi tim dan pegang konsisten.

Dengan CHECK, papan constraint inti sudah lengkap: primary key menjaga identitas, foreign key menjaga relasi, unique menjaga keunikan, not null menjaga keberadaan, dan check menjaga aturan bisnis. Masih ada satu anggota keluarga kecil yang menunggu: DEFAULT — cara membuat nilai terisi otomatis tanpa pemanggil ikut menyebutkannya, topik chapter berikutnya.

Referensi #

Berkas Sumber

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

check-dasar.sql

Unduh berkas
sql
-- CHECK pertama: tolak harga dan stok yang tidak masuk akal
CREATE TABLE barang (
    id    bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    nama  text NOT NULL,
    harga numeric(12, 2) NOT NULL,
    stok  integer NOT NULL DEFAULT 0,
    CONSTRAINT chk_harga_positif CHECK (harga >= 0),
    CONSTRAINT chk_stok_tak_negatif CHECK (stok >= 0)
);

INSERT INTO barang (nama, harga, stok) VALUES
    ('Kopi Arabika Gayo 250g', 85000.00, 40),
    ('Teh Melati Kotak 100g',   15000.00, 120);

SELECT id, nama, harga, stok FROM barang ORDER BY id;

\d barang

check-ekspresi.sql

Unduh berkas
sql
-- Empat idiom CHECK: perbandingan sudah, lalu IN, BETWEEN, LIKE, char_length
CREATE TABLE pegawai (
    id      bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    nip     text NOT NULL,
    nama    text NOT NULL,
    status  text NOT NULL,
    rating  integer,
    catatan text,
    CONSTRAINT chk_nip_format CHECK (nip LIKE 'NIP-%'),
    CONSTRAINT chk_status_daftar CHECK (status IN ('aktif', 'cuti', 'keluar')),
    CONSTRAINT chk_rating_rentang CHECK (rating BETWEEN 1 AND 5),
    CONSTRAINT chk_catatan_panjang CHECK (char_length(catatan) <= 200)
);

INSERT INTO pegawai (nip, nama, status, rating, catatan) VALUES
    ('NIP-0001', 'Budi Santoso', 'aktif', 4, 'Pelayanan prima'),
    ('NIP-0002', 'Siti Rahayu',  'cuti',  NULL, NULL);

SELECT id, nip, nama, status, rating, catatan FROM pegawai ORDER BY id;

check-multi-kolom.sql

Unduh berkas
sql
-- CHECK bisa membandingkan dua kolom dalam satu baris
CREATE TABLE proyek (
    id              bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    nama_proyek     text NOT NULL,
    tanggal_mulai   date NOT NULL,
    tanggal_selesai date,
    nilai_kontrak   numeric(14, 2) NOT NULL,
    CONSTRAINT chk_tanggal_berurutan CHECK (tanggal_selesai >= tanggal_mulai)
);

INSERT INTO proyek (nama_proyek, tanggal_mulai, tanggal_selesai, nilai_kontrak) VALUES
    ('Pembangunan Website', DATE '2026-01-10', DATE '2026-03-15', 250000000.00),
    ('Riset Pasar',         DATE '2026-02-01', NULL,                15000000.00);

SELECT id, nama_proyek, tanggal_mulai, tanggal_selesai, nilai_kontrak
FROM proyek
ORDER BY id;

check-not-valid.sql

Unduh berkas
sql
-- NOT VALID: pasang CHECK pada tabel berisi data lama yang melanggar
CREATE TABLE barang_lama (
    id    integer PRIMARY KEY,
    nama  text NOT NULL,
    harga numeric(12, 2)
);

-- Dua baris lama; yang pertama hasil bug zaman dahulu
INSERT INTO barang_lama (id, nama, harga) VALUES
    (1, 'Produk Rusak A', -5000.00),
    (2, 'Produk Baik B',  12000.00);

ALTER TABLE barang_lama
    ADD CONSTRAINT chk_harga_positif CHECK (harga >= 0) NOT VALID;

\d barang_lama

-- Perbaiki data lama, lalu validasikan constraint
UPDATE barang_lama SET harga = 5000.00 WHERE id = 1;

ALTER TABLE barang_lama VALIDATE CONSTRAINT chk_harga_positif;

\d barang_lama

check-pelanggaran.sql

Unduh berkas
sql
INSERT INTO barang (nama, harga, stok)
VALUES ('Gula Kristal Putih 1kg', -16000.00, 50);

check-subquery.sql

Unduh berkas
sql
-- CHECK tidak boleh membaca tabel lain: ditolak sejak statement CREATE TABLE
CREATE TABLE penjualan_importir (
    id     bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    jumlah numeric(12, 2) NOT NULL,
    CHECK (jumlah <= (SELECT max(plafon) FROM batasan_kredit))
);

check-tanggal.sql

Unduh berkas
sql
INSERT INTO proyek (nama_proyek, tanggal_mulai, tanggal_selesai, nilai_kontrak) VALUES
    ('Audit Stok', DATE '2026-04-01', DATE '2026-03-20', 8000000.00);