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:
| Kebutuhan | Tipe | Alasan singkat |
|---|---|---|
| Uang dan presisi desimal | numeric(p, s) | bebas galat pembulatan; float tidak |
| Flag ya/tidak | boolean | ekspresi WHERE aktif alami, bukan WHERE status = 'Y' |
| Hitungan dan jumlah | integer / bigint | efisien; pilih sesuai rentang maksimum |
| Identifier baris (satu database) | integer/bigint + GENERATED AS IDENTITY | nomor urut pendek yang dijamin server |
| Identifier lintas sistem | uuid | unik tanpa koordinasi antar server |
| Nama, alamat, deskripsi | text | tanpa batas buatan; varchar tanpa n identik dengan text |
| Kode dengan batas panjang nyata | varchar(n) | batas jadi aturan yang ditegakkan server |
| Daftar nilai tetap | enum atau CHECK constraint | nilai di luar daftar ditolak |
| Tanggal tanpa jam | date | ringkas dan jelas |
| Waktu kejadian | timestamptz | zona waktu tersimpan aman |
| Durasi antar kejadian | interval | aritmetika tanggal yang ekspresif |
| Jadwal dan masa berlaku | daterange / tstzrange | operator tumpang tindih && bawaan |
| Daftar pendek yang jarang di-query per anggota | text[] | ringkas untuk label, tag |
| Daftar yang serius (sering di-query, dihubungkan) | tabel perantara + foreign key | kekuatan penuh relasional — dibahas di Section Table Design |
| Data semi-terstruktur | jsonb | diindeks dan di-query dengan operator JSON |
| Hash, biner kecil, blob terenkripsi | bytea | wadah byte mentah yang tervalidasi |
| Alamat IP dan jaringan | inet / cidr | validasi plus operator subnet |
| Koordinat sederhana | point (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:
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:
id bigint GENERATED ALWAYS AS IDENTITY: nomor dihasilkan server secara berurutan;bigintdipilih karena tabel produk berpotensi tumbuh besar.GENERATED ALWAYSmencegah aplikasi menyisipkan id liar — kebetulan juga yang membuatINSERTdi atas tidak perlu menyebut kolom ini sama sekali.nama text NOT NULL: tidak adavarchar(255)— nama produk tidak punya aturan panjang, dantexttidak membatasi sama sekali.harga numeric(12, 2) CHECK (harga >= 0):numericuntuk akurasi,CHECKmenolak harga negatif yang tidak masuk akal bisnis. Dua digit desimal menampung harga seperti15750.50.berat_gram integer: boleh NULL karena tidak semua produk berbobot (jasa, misalnya), danintegermuat hingga 2,1 miliar gram.aktif boolean NOT NULL DEFAULT true: produk baru langsung aktif; flag dua keadaan memang rumahnyaboolean.dibuat_pada timestamptz NOT NULL: momen kejadian disertai zona waktu — nilai+07tampil apa adanya di output.masa_promo daterange: satu kolom menggantikanpromo_mulai/promo_selesai, dan produk tanpa promo cukup NULL. Periode kopi Arabika tampil sebagai[2026-02-01,2026-02-15).tag text[]danmetadata jsonb: tag berupa daftar label yang jarang di-query per anggota; atribut longgar (asal, rating) hidup dijsonb— 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:
-- 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:
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:
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:
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-pola | Kenapa salah | Perbaikannya |
|---|---|---|
char(n) untuk kode pendek | padding spasi sampai panjang n, membingungkan perbandingan | text atau varchar(n) |
char(1) berisi 'Y'/'N' | tidak bisa NULL dengan aman, filter berbasis teks | boolean |
timestamp tanpa tz untuk waktu kejadian | zona waktu hilang, ambigu saat server pindah zona | timestamptz |
Menyimpan uang sebagai integer rupiah utuh | rapuh: desimal, pembulatan, dokumentasi | numeric(12, 2) |
Tabel EAV (kolom atribut/nilai serba teks) | semua tipe hilang, query berat, integrity nol | kolom 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-- 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-- 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-- 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 berkasINSERT INTO voucher (kode, potongan)
VALUES ('MAJUJAYA2026', 10000);