Kita telah menelusuri lima belas keluarga tipe data PostgreSQL: numerik, teks, waktu, identifier, enum, array, JSONB, bytea, alamat jaringan, bentuk geometri, hingga rentang nilai. Katalog seluas itu memunculkan pertanyaan di meja desain: untuk setiap kolom yang akan Anda definisikan, tipe mana yang tepat? Chapter penutup ini merangkum seluruh section menjadi tabel keputusan, membedah anti-pola paling merugikan dengan bukti yang bisa dijalankan, lalu ditutup latihan merakit satu tabel produk yang terketik benar dari atas ke bawah.

Kerangka Memilih: Tipe Paling Spesifik yang Memenuhi Kebutuhan #

Semua aturan di chapter ini bermuara pada satu prinsip: pilih tipe paling spesifik yang tetap memenuhi kebutuhan. Tipe yang spesifik membawa tiga keuntungan sekaligus: akurasi — numeric menghitung uang tanpa galat pembulatan, timestamptz menyimpan momen waktu tanpa ambigu zona; integritas — inet menolak alamat mustahil, daterange menolak batas terbalik; dan kecepatan — representasi biner yang tepat ukurannya menyimpan lebih hemat dan dibandingkan lebih cepat daripada teks yang harus diparsing setiap kali.

Tabel Keputusan per Kebutuhan #

Berikut pemetaan kebutuhan bisnis ke tipe PostgreSQL yang telah kita bahas sepanjang section ini:

KebutuhanTipeAlasan singkat
Uang dan presisi desimalnumeric(p, s)bebas galat pembulatan; float tidak
Flag ya/tidakbooleanekspresi WHERE aktif alami, bukan WHERE status = 'Y'
Hitungan dan jumlahinteger / bigintefisien; pilih sesuai rentang maksimum
Identifier baris (satu database)integer/bigint + GENERATED AS IDENTITYnomor urut pendek yang dijamin server
Identifier lintas sistemuuidunik tanpa koordinasi antar server
Nama, alamat, deskripsitexttanpa batas buatan; varchar tanpa n identik dengan text
Kode dengan batas panjang nyatavarchar(n)batas jadi aturan yang ditegakkan server
Daftar nilai tetapenum atau CHECK constraintnilai di luar daftar ditolak
Tanggal tanpa jamdateringkas dan jelas
Waktu kejadiantimestamptzzona waktu tersimpan aman
Durasi antar kejadianintervalaritmetika tanggal yang ekspresif
Jadwal dan masa berlakudaterange / tstzrangeoperator tumpang tindih && bawaan
Daftar pendek yang jarang di-query per anggotatext[]ringkas untuk label, tag
Daftar yang serius (sering di-query, dihubungkan)tabel perantara + foreign keykekuatan penuh relasional — dibahas di Section Table Design
Data semi-terstrukturjsonbdiindeks dan di-query dengan operator JSON
Hash, biner kecil, blob terenkripsibyteawadah byte mentah yang tervalidasi
Alamat IP dan jaringaninet / cidrvalidasi plus operator subnet
Koordinat sederhanapoint (geometri)jarak bidang datar; PostGIS untuk bumi

Tiga baris terakhir menegaskan batas masing-masing: array untuk label ringan, tabel perantara untuk data relasional sungguhan; point untuk kasus sederhana, PostGIS untuk geospasial serius. Tipe yang kurang dari kebutuhan menyusahkan — tetapi yang jauh lebih berat juga membebani.

Kuis Mini: Mengetik Tabel Produk #

Saatnya berlatih. Bayangkan Anda mendesain tabel produk untuk Toko Maju Jaya dengan kebutuhan berikut: identitas baris otomatis, nama bebas panjangnya, harga dalam rupiah dengan dua desimal, berat dalam gram, status aktif/nonaktif, waktu pencatatan, periode promo yang kadang kosong, daftar tag, dan atribut tambahan yang strukturnya berubah-ubah antar kategori produk. Coba tulis dulu versi Anda, lalu bandingkan dengan definisi berikut:

-- Desain terketik untuk tabel produk Toko Maju Jaya
CREATE TABLE produk (
    -- Identifier: bigint identity, server yang menomori
    id           bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    -- Nama bebas: text, tanpa batas panjang buatan
    nama         text NOT NULL,
    -- Uang: numeric pasti, bukan float
    harga        numeric(12, 2) NOT NULL CHECK (harga >= 0),
    -- Hitungan kecil: integer
    berat_gram   integer,
    -- Flag: boolean, bukan char(1) 'Y'/'N'
    aktif        boolean NOT NULL DEFAULT true,
    -- Waktu kejadian dengan zona waktu: timestamptz
    dibuat_pada  timestamptz NOT NULL,
    -- Periode promo: satu daterange, bukan dua kolom mulai/selesai
    masa_promo   daterange,
    -- Daftar label pendek: text[]
    tag          text[] NOT NULL DEFAULT '{}',
    -- Atribut longgar yang jarang di-query: jsonb
    metadata     jsonb
);

INSERT INTO produk (nama, harga, berat_gram, dibuat_pada, masa_promo, tag, metadata) VALUES
    ('Kopi Arabika Gayo 250g', 85000, 260,
     TIMESTAMPTZ '2026-01-15 08:00:00+07',
     daterange('2026-02-01', '2026-02-15', '[)'),
     ARRAY['kopi', 'premium'],
     '{"asal": "Aceh", "rating": 4.8}'::jsonb),
    ('Teh Melati Kotak 100g', 15000, 110,
     TIMESTAMPTZ '2026-01-15 08:05:00+07',
     NULL,
     ARRAY['teh'],
     '{"asal": "Jawa Barat"}'::jsonb),
    ('Gula Kristal Putih 1kg', 16000, 1010,
     TIMESTAMPTZ '2026-01-16 09:30:00+07',
     NULL,
     ARRAY['bahan pokok'],
     NULL);

SELECT id, nama, harga, berat_gram, aktif, dibuat_pada, masa_promo, tag
FROM produk
ORDER BY id;

Output yang Dihasilkan:

text
CREATE TABLE
INSERT 0 3
 id |          nama          |  harga   | berat_gram | aktif |      dibuat_pada       |       masa_promo        |       tag       
----+------------------------+----------+------------+-------+------------------------+-------------------------+-----------------
  1 | Kopi Arabika Gayo 250g | 85000.00 |        260 | t     | 2026-01-15 08:00:00+07 | [2026-02-01,2026-02-15) | {kopi,premium}
  2 | Teh Melati Kotak 100g  | 15000.00 |        110 | t     | 2026-01-15 08:05:00+07 |                         | {teh}
  3 | Gula Kristal Putih 1kg | 16000.00 |       1010 | t     | 2026-01-16 09:30:00+07 |                         | {"bahan pokok"}
(3 rows)

Mari kita bedah keputusan tiap kolom:

  1. id bigint GENERATED ALWAYS AS IDENTITY: nomor dihasilkan server secara berurutan; bigint dipilih karena tabel produk berpotensi tumbuh besar. GENERATED ALWAYS mencegah aplikasi menyisipkan id liar — kebetulan juga yang membuat INSERT di atas tidak perlu menyebut kolom ini sama sekali.
  2. nama text NOT NULL: tidak ada varchar(255) — nama produk tidak punya aturan panjang, dan text tidak membatasi sama sekali.
  3. harga numeric(12, 2) CHECK (harga >= 0): numeric untuk akurasi, CHECK menolak harga negatif yang tidak masuk akal bisnis. Dua digit desimal menampung harga seperti 15750.50.
  4. berat_gram integer: boleh NULL karena tidak semua produk berbobot (jasa, misalnya), dan integer muat hingga 2,1 miliar gram.
  5. aktif boolean NOT NULL DEFAULT true: produk baru langsung aktif; flag dua keadaan memang rumahnya boolean.
  6. dibuat_pada timestamptz NOT NULL: momen kejadian disertai zona waktu — nilai +07 tampil apa adanya di output.
  7. masa_promo daterange: satu kolom menggantikan promo_mulai/promo_selesai, dan produk tanpa promo cukup NULL. Periode kopi Arabika tampil sebagai [2026-02-01,2026-02-15).
  8. tag text[] dan metadata jsonb: tag berupa daftar label yang jarang di-query per anggota; atribut longgar (asal, rating) hidup di jsonb — tidak di-SELECT di atas karena tampilannya panjang, tapi tersimpan lengkap.

Anti-Pola 1: Float untuk Uang #

Kesalahan klasik yang paling mahal dampaknya. float4/float8 menyimpan bilangan dalam representasi biner, dan bilangan desimal sederhana pun bisa kehilangan presisi di sana. Bukti berikut bisa langsung dijalankan:

sql kasus-uang.sql
-- Race klasik: float8 vs numeric untuk uang
CREATE TABLE pengujian_pembayaran (
    id        integer PRIMARY KEY,
    jumlah_f  float8 NOT NULL,
    jumlah_n  numeric(12, 2) NOT NULL
);

INSERT INTO pengujian_pembayaran (id, jumlah_f, jumlah_n) VALUES
    (1, 0.10, 0.10),
    (2, 0.20, 0.20);

SELECT sum(jumlah_f) AS total_float,
       sum(jumlah_n) AS total_numeric
FROM pengujian_pembayaran;

SELECT sum(jumlah_f) = 0.30 AS float_tepat,
       sum(jumlah_n) = 0.30 AS numeric_tepat
FROM pengujian_pembayaran;

Output yang Dihasilkan:

text
CREATE TABLE
INSERT 0 2
     total_float     | total_numeric 
---------------------+---------------
 0.30000000000000004 |          0.30
(1 row)

 float_tepat | numeric_tepat 
-------------+---------------
 f           | t
(1 row)

Hasilnya bicara sendiri: menjumlahkan 0.10 dan 0.20 di kolom float8 menghasilkan 0.30000000000000004, dan pembandingan = 0.30 bernilai salah; kolom numeric menghasilkan 0.30 yang tepat. Dua baris saja sudah melenceng — bayangkan jutaan transaksi per bulan dengan laporan yang melenceng sedikit demi sedikit. Untuk segala hal yang bernilai uang: numeric.

Anti-Pola 2: varchar(255) untuk Semua Teks #

Kebiasaan varchar(255) adalah warisan database lama yang membatasi panjang default; di PostgreSQL, varchar tanpa angka identik dengan text, dan text sendiri tidak lebih lambat. Menempelkan varchar(255) pada semua kolom teks berarti memasang batas yang bukan aturan bisnis — dan batas itu tetap ditegakkan saat paling tidak tepat:

-- varchar(n) dipakai bila batas panjang memang aturan bisnis
CREATE TABLE voucher (
    kode       varchar(8) PRIMARY KEY,
    potongan   numeric(10, 2) NOT NULL CHECK (potongan > 0)
);

INSERT INTO voucher (kode, potongan) VALUES
    ('MAJU2026', 25000);

SELECT kode, potongan
FROM voucher
ORDER BY kode;

Output yang Dihasilkan:

text
CREATE TABLE
INSERT 0 1
   kode   | potongan 
----------+----------
 MAJU2026 | 25000.00
(1 row)

Kode voucher berformat delapan karakter adalah aturan bisnis sungguhan — di sinilah varchar(8) bermain peran, karena server kini ikut menolak kode yang melanggar format:

INSERT INTO voucher (kode, potongan)
VALUES ('MAJUJAYA2026', 10000);

Output yang Dihasilkan:

text
psql:voucher-kepanjangan.sql:2: ERROR:  value too long for type character varying(8)

Pesan value too long for type character varying(8) menegaskan dua hal sekaligus: batas bekerja, dan batas bekerja mentah-mentah — tanpa pesan ramah atau pemotongan otomatis. Polanya begini: text untuk semua teks bebas, varchar(n) hanya ketika panjang n memang bagian dari definisi data (kode, nomor seri, singkatan baku), dan CHECK (char_length(kolom) <= n) bila batas perlu pesan error yang bisa dikustomisasi.

Anti-Pola Lain yang Sering Dijumpai #

Anti-polaKenapa salahPerbaikannya
char(n) untuk kode pendekpadding spasi sampai panjang n, membingungkan perbandingantext atau varchar(n)
char(1) berisi 'Y'/'N'tidak bisa NULL dengan aman, filter berbasis teksboolean
timestamp tanpa tz untuk waktu kejadianzona waktu hilang, ambigu saat server pindah zonatimestamptz
Menyimpan uang sebagai integer rupiah utuhrapuh: desimal, pembulatan, dokumentasinumeric(12, 2)
Tabel EAV (kolom atribut/nilai serba teks)semua tipe hilang, query berat, integrity nolkolom bertipe sungguhan atau jsonb

Dari Tipe ke Struktur Tabel #

Tipe data yang tepat adalah separuh dari desain tabel yang sehat; separuh sisanya adalah constraint yang mengikatnya (PRIMARY KEY, FOREIGN KEY, UNIQUE, CHECK) dan relasi antar tabel yang menentukan bentuk skema. Kerangka berpikirnya sama: nyatakan aturan sedekat mungkin dengan data, agar server yang menegakkannya. Kita sudah melihat cara kerjanya lewat CHECK (harga >= 0), EXCLUDE anti-benturan, dan enum — dan itulah pintu masuk Section berikutnya: Table Design, tempat tipe-tipe yang baru saja kita pilih dirakit menjadi tabel, relasi, dan skema yang utuh.

Referensi #

Berkas Sumber

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

buat-voucher.sql

Unduh berkas
sql
-- varchar(n) dipakai bila batas panjang memang aturan bisnis
CREATE TABLE voucher (
    kode       varchar(8) PRIMARY KEY,
    potongan   numeric(10, 2) NOT NULL CHECK (potongan > 0)
);

INSERT INTO voucher (kode, potongan) VALUES
    ('MAJU2026', 25000);

SELECT kode, potongan
FROM voucher
ORDER BY kode;

kasus-uang.sql

Unduh berkas
sql
-- Race klasik: float8 vs numeric untuk uang
CREATE TABLE pengujian_pembayaran (
    id        integer PRIMARY KEY,
    jumlah_f  float8 NOT NULL,
    jumlah_n  numeric(12, 2) NOT NULL
);

INSERT INTO pengujian_pembayaran (id, jumlah_f, jumlah_n) VALUES
    (1, 0.10, 0.10),
    (2, 0.20, 0.20);

SELECT sum(jumlah_f) AS total_float,
       sum(jumlah_n) AS total_numeric
FROM pengujian_pembayaran;

SELECT sum(jumlah_f) = 0.30 AS float_tepat,
       sum(jumlah_n) = 0.30 AS numeric_tepat
FROM pengujian_pembayaran;

tabel-produk.sql

Unduh berkas
sql
-- Desain terketik untuk tabel produk Toko Maju Jaya
CREATE TABLE produk (
    -- Identifier: bigint identity, server yang menomori
    id           bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    -- Nama bebas: text, tanpa batas panjang buatan
    nama         text NOT NULL,
    -- Uang: numeric pasti, bukan float
    harga        numeric(12, 2) NOT NULL CHECK (harga >= 0),
    -- Hitungan kecil: integer
    berat_gram   integer,
    -- Flag: boolean, bukan char(1) 'Y'/'N'
    aktif        boolean NOT NULL DEFAULT true,
    -- Waktu kejadian dengan zona waktu: timestamptz
    dibuat_pada  timestamptz NOT NULL,
    -- Periode promo: satu daterange, bukan dua kolom mulai/selesai
    masa_promo   daterange,
    -- Daftar label pendek: text[]
    tag          text[] NOT NULL DEFAULT '{}',
    -- Atribut longgar yang jarang di-query: jsonb
    metadata     jsonb
);

INSERT INTO produk (nama, harga, berat_gram, dibuat_pada, masa_promo, tag, metadata) VALUES
    ('Kopi Arabika Gayo 250g', 85000, 260,
     TIMESTAMPTZ '2026-01-15 08:00:00+07',
     daterange('2026-02-01', '2026-02-15', '[)'),
     ARRAY['kopi', 'premium'],
     '{"asal": "Aceh", "rating": 4.8}'::jsonb),
    ('Teh Melati Kotak 100g', 15000, 110,
     TIMESTAMPTZ '2026-01-15 08:05:00+07',
     NULL,
     ARRAY['teh'],
     '{"asal": "Jawa Barat"}'::jsonb),
    ('Gula Kristal Putih 1kg', 16000, 1010,
     TIMESTAMPTZ '2026-01-16 09:30:00+07',
     NULL,
     ARRAY['bahan pokok'],
     NULL);

SELECT id, nama, harga, berat_gram, aktif, dibuat_pada, masa_promo, tag
FROM produk
ORDER BY id;

voucher-kepanjangan.sql

Unduh berkas
sql
INSERT INTO voucher (kode, potongan)
VALUES ('MAJUJAYA2026', 10000);