Database Toko Maju Jaya tumbuh ke dua arah yang berbeda nasib: petugas gudang bekerja dengan data stok yang berubah setiap jam, manajer membaca ringkasan bulanan yang disegarkan sehari sekali, dan keduanya kini menumpuk di satu kerumunan tabel public. Nama tabel mulai bertabrakan — dua versi produk untuk dua kebutuhan — dan daftar tabel yang panjang membuat database terasa seperti gudang tanpa rak. PostgreSQL menjawabnya dengan schema: ruang nama (namespace) di dalam satu database, sehingga inventaris.produk dan laporan.penjualan_bulanan hidup berdampingan tanpa pernah saling menabrak. Seiring dengan itu muncul satu mekanisme yang menentukan tabel mana yang menjawab setiap kali Anda menulis nama polos seperti produk — search_path — dan mekanisme itu punya aturan main yang wajib dipahami sebelum salah resolusi membuat query membaca tabel yang salah.

Skema sebagai Ruang Nama #

Skema berada satu tingkat di bawah database: satu database menampung banyak skema, satu skema menampung banyak tabel. Berbeda dengan pemisahan ke database lain — yang memutus hubungan total hingga join pun tak lagi bisa dilakukan — pemisahan ke skema tetap berada dalam satu database, jadi tabel lintas skema masih bisa di-join kapan saja. Yang berubah hanyalah penamaannya:

sql buat-skema.sql
-- Skema memisahkan ranah di dalam satu database:
-- inventaris untuk data gudang, laporan untuk tabel ringkasan manajemen.
CREATE SCHEMA inventaris;
CREATE SCHEMA laporan;

-- Nama objek kini ditulis terkualifikasi: skema.tabel.
CREATE TABLE inventaris.produk (
    kode text PRIMARY KEY,
    nama text NOT NULL,
    stok  integer NOT NULL CHECK (stok >= 0)
);

CREATE TABLE laporan.penjualan_bulanan (
    bulan date PRIMARY KEY,
    total numeric(14, 2) NOT NULL
);

INSERT INTO inventaris.produk (kode, nama, stok) VALUES
    ('P01', 'Buku Tulis',   120),
    ('P02', 'Pulpen Hitam',  85),
    ('P03', 'Penggaris',     40);

INSERT INTO laporan.penjualan_bulanan (bulan, total) VALUES
    (DATE '2026-01-01', 75000.00),
    (DATE '2026-02-01', 35500.00);

-- Dua tabel hidup berdampingan tanpa saling bertabrakan nama.
SELECT kode, nama, stok
FROM inventaris.produk
ORDER BY kode;

SELECT bulan, total
FROM laporan.penjualan_bulanan
ORDER BY bulan;

-- Daftar skema yang terdaftar di database ini, dibaca dari catalog sistem.
SELECT nspname AS skema
FROM pg_namespace
WHERE nspname IN ('public', 'inventaris', 'laporan')
ORDER BY nspname;

Output yang Dihasilkan:

text
CREATE SCHEMA
CREATE SCHEMA
CREATE TABLE
CREATE TABLE
INSERT 0 3
INSERT 0 2
 kode |     nama     | stok 
------+--------------+------
 P01  | Buku Tulis   |  120
 P02  | Pulpen Hitam |   85
 P03  | Penggaris    |   40
(3 rows)

   bulan    |  total   
------------+----------
 2026-01-01 | 75000.00
 2026-02-01 | 35500.00
(2 rows)

   skema    
------------
 inventaris
 laporan
 public
(3 rows)

Tiga hal terjadi di file itu. CREATE SCHEMA inventaris dan CREATE SCHEMA laporan membuka dua ruang nama baru di dalam database. Seluruh CREATE TABLE dan INSERT kemudian memakai nama terkualifikasi skema.tabel — pola inventaris.produk yang akan menjadi kebiasaan tetap Anda. Query penutup membaca pg_namespace, catalog sistem tempat PostgreSQL mencatat seluruh skema; hasilnya memperlihatkan inventaris, laporan, dan public berdiri berdampingan. Skema public itu hadir sejak database ini dibuat — selama ini semua tabel kita memang menumpang di sana tanpa pernah menyebut namanya.

Search Path dan Resolusi Nama #

Menulis inventaris.produk di setiap query tentu melelahkan, jadi PostgreSQL menyediakan search_path: daftar skema yang diperiksa berurutan setiap kali sebuah nama ditulis tanpa kualifikasi. Eksperimen berikut menempatkan dua tabel bernama persis sama — produk — di dua skema berbeda, lalu menggeser-geser jalur pencariannya:

-- Eksperimen resolusi nama: dua tabel bernama sama, produk, di dua skema.
-- public.produk menyimpan harga katalog; inventaris.produk menyimpan stok.
CREATE TABLE public.produk (
    kode  text PRIMARY KEY,
    nama  text NOT NULL,
    harga numeric(12, 2) NOT NULL
);

INSERT INTO public.produk (kode, nama, harga) VALUES
    ('P01', 'Buku Tulis',   4500.00),
    ('P02', 'Pulpen Hitam', 3000.00),
    ('P03', 'Penggaris',    2500.00);

-- search_path bawaan: skema bernama sama dengan pengguna (bila ada),
-- lalu public.
SHOW search_path;

SELECT current_schema();

-- Nama polos "produk" jatuh ke public: kolom harga yang menjawab.
SELECT *
FROM produk
ORDER BY kode;

-- Ganti urutan pencarian: inventaris didahulukan.
SET search_path TO inventaris, public;

SHOW search_path;

SELECT current_schema();

-- Nama polos "produk" kini jatuh ke inventaris: kolom stok yang menjawab.
SELECT *
FROM produk
ORDER BY kode;

-- Cukup public saja di jalur pencarian -- inventaris tidak lagi dijangkau
-- tanpa nama terkualifikasi.
SET search_path TO public;

SELECT current_schema();

SELECT *
FROM produk
ORDER BY kode;

-- Kembalikan nilai bawaan untuk sesi ini.
RESET search_path;

SELECT current_schema();

Output yang Dihasilkan:

text
CREATE TABLE
INSERT 0 3
   search_path   
-----------------
 "$user", public
(1 row)

 current_schema 
----------------
 public
(1 row)

 kode |     nama     |  harga  
------+--------------+---------
 P01  | Buku Tulis   | 4500.00
 P02  | Pulpen Hitam | 3000.00
 P03  | Penggaris    | 2500.00
(3 rows)

SET
    search_path     
--------------------
 inventaris, public
(1 row)

 current_schema 
----------------
 inventaris
(1 row)

 kode |     nama     | stok 
------+--------------+------
 P01  | Buku Tulis   |  120
 P02  | Pulpen Hitam |   85
 P03  | Penggaris    |   40
(3 rows)

SET
 current_schema 
----------------
 public
(1 row)

 kode |     nama     |  harga  
------+--------------+---------
 P01  | Buku Tulis   | 4500.00
 P02  | Pulpen Hitam | 3000.00
 P03  | Penggaris    | 2500.00
(3 rows)

RESET
 current_schema 
----------------
 public
(1 row)

Urutan kejadian di file itu adalah seluruh mekanisme resolusi nama. Nilai bawaan search_path adalah "$user", public: entri pertama mencari skema yang namanya sama dengan nama pengguna — di sesi kita, skema bernama postgres tidak ada, jadi entri itu dilewati — lalu jatuh ke public. Query SELECT * FROM produk pertama dijawab kolom harga: tabel public.produk yang menang. Setelah SET search_path TO inventaris, public, nama polos yang sama dijawab kolom stok — tabel inventaris.produk yang kini lebih dulu ditemukan — dan current_schema() berubah menjadi inventaris. Mempersempit jalur ke public saja membuat inventaris.produk tak lagi terjangkau tanpa nama terkualifikasi, dan RESET search_path mengembalikan semuanya. Ringkasnya:

Aturan resolusiPerilaku
nama terkualifikasi skema.tabelselalu menunjuk objek itu, jalur pencarian diabaikan
nama polosskema pertama di search_path yang memuat objek itu yang menang
objek tidak ditemukan di seluruh jalurerror relation does not exist
SET search_pathhanya berlaku untuk sesi itu; koneksi baru mulai dari nilai bawaan

pg_catalog Selalu Dicari Lebih Dulu #

Satu skema tidak pernah tampil di SHOW search_path tetapi selalu ikut bekerja: pg_catalog, rumah bagi catalog sistem — pg_namespace, pg_tables, fungsi seperti pg_typeof. PostgreSQL mencari skema ini lebih dulu secara implisit, tepat sebelum entri pertama search_path:

-- pg_catalog tidak pernah tampil di search_path,
-- tetapi isi katalog sistem selalu bisa diakses tanpa kualifikasi.
SHOW search_path;

-- Fungsi sistem seperti pg_typeof ditemukan padahal skema rumahnya
-- (pg_catalog) tidak tertulis di jalur pencarian.
SELECT pg_typeof(1) AS tipe_angka,
       pg_typeof(DATE '2026-01-01') AS tipe_tanggal;

-- Penulisan terkualifikasi memberi hasil yang sama --
-- bukti bahwa rumah aslinya memang pg_catalog.
SELECT pg_catalog.pg_typeof(1) AS tipe_angka_terkualifikasi;

-- pg_tables membaca catalog sistem: tabel kita tercatat lengkap
-- dengan nama skemanya.
SELECT schemaname, tablename
FROM pg_tables
WHERE schemaname IN ('public', 'inventaris', 'laporan')
ORDER BY schemaname, tablename;

Output yang Dihasilkan:

text
   search_path   
-----------------
 "$user", public
(1 row)

 tipe_angka | tipe_tanggal 
------------+--------------
 integer    | date
(1 row)

 tipe_angka_terkualifikasi 
---------------------------
 integer
(1 row)

 schemaname |     tablename     
------------+-------------------
 inventaris | produk
 laporan    | penjualan_bulanan
 public     | produk
(3 rows)

SHOW search_path menegaskan pg_catalog tidak tertulis di jalur, tetapi pg_typeof(1) tetap ditemukan — dan versi terkualifikasi pg_catalog.pg_typeof(1) mengembalikan hasil yang sama, bukti rumah aslinya. Konsekuensi praktisnya satu kalimat: jangan menamai objek buatan Anda sama dengan objek catalog sistem, karena pencarian pg_catalog yang didahululkan bisa membuat nama Anda tak pernah terjangkau tanpa kualifikasi penuh. Query penutup memakai pg_tables untuk memetakan hasil kerja kita: inventaris.produk, laporan.penjualan_bulanan, dan public.produk tercatat berdampingan dengan skemanya masing-masing.

Hak CREATE pada Schema public #

Selama ini kita membuat tabel di public tanpa pernah ditanya izin — karena sesi berjalan sebagai postgres, pengguna superuser yang melewati semua pemeriksaan hak akses. Nasib role biasa sangat berbeda, dan perubahannya penting: sejak PostgreSQL 15, schema public tidak lagi memberikan hak CREATE kepada semua pengguna. Hak itu kini hanya dimiliki pemilik database. File berikut menyiapkan role biasa lalu membandingkan haknya:

sql hak-akses.sql
-- Menyiapkan role biasa untuk menguji hak akses schema public.
DROP ROLE IF EXISTS petugas_tokomaju;
CREATE ROLE petugas_tokomaju LOGIN;

-- Bandingkan dua sisi: superuser postgres dan role baru petugas_tokomaju.
-- USAGE = boleh menyebut objek di skema itu; CREATE = boleh membuat objek baru.
SELECT has_schema_privilege('postgres', 'public', 'USAGE')  AS superuser_usage,
       has_schema_privilege('postgres', 'public', 'CREATE') AS superuser_create,
       has_schema_privilege('petugas_tokomaju', 'public', 'USAGE')  AS petugas_usage,
       has_schema_privilege('petugas_tokomaju', 'public', 'CREATE') AS petugas_create;

Output yang Dihasilkan:

text
DROP ROLE
CREATE ROLE
 superuser_usage | superuser_create | petugas_usage | petugas_create 
-----------------+------------------+---------------+----------------
 t               | t                | t             | f
(1 row)

Satu baris hasil itu memuat seluruh cerita: superuser_usage dan superuser_create bernilai t, petugas_usage bernilai t, tetapi petugas_create bernilai f. Role petugas_tokomaju boleh menyebut dan membaca tabel di public (hak USAGE tetap terbuka), namun tidak boleh membuat satu pun objek baru di sana. Percobaan langsungnya menjawab lebih tegas lagi:

sql hak-public.sql
-- Sejak PostgreSQL 15, role biasa tidak lagi boleh membuat tabel
-- di schema public. Percobaan berikut membuktikannya secara langsung:
-- sesi berpura-pura menjadi petugas_tokomaju, lalu mencoba CREATE TABLE.
SET ROLE petugas_tokomaju;

CREATE TABLE public.catatan_uji (id integer);

Output yang Dihasilkan:

text
psql:hak-public.sql:6: ERROR:  permission denied for schema public
LINE 1: CREATE TABLE public.catatan_uji (id integer);
                     ^

Server menjawab permission denied for schema public dan membatalkan CREATE TABLE. Sebelum PostgreSQL 15, percobaan yang sama justru berhasil — semua pengguna boleh membuat objek di public — dan kebiasaan lama itu masih hidup di banyak tutorial maupun aplikasi lawas. Migrasi ke PostgreSQL 15 atau yang lebih baru kerap memunculkan error ini untuk pertama kalinya, dan pemecahannya bukan memberikan CREATE sembarangan, melainkan menyediakan skema milik aplikasi itu sendiri.

Skema untuk Multi-Tenant dan Keamanan #

Pola schema-per-tenant adalah penggunaan lanjutan yang layak dikenali: aplikasi yang melayani banyak tenant (banyak toko, banyak cabang) bisa memberi setiap tenant satu skema — tenant_maju.produk, tenant_sejahtera.produk — sehingga data terpisah rapi dan hak akses diatur per tenant tanpa mengubah satu pun query bila search_path disetel per koneksi. Alternatifnya, satu skema bersama dengan kolom tenant_id di setiap tabel, yang mengorbankan pemisahan nama demi kerapatan query. Pilihan di antara keduanya adalah keputusan arsitektur, bukan sintaks — dan bab mengenai keamanan data akan menunjukkan bagaimana skema menjadi lapisan isolasi ketika digabungkan dengan hak akses dan role.

Menghapus Skema #

Siklus hidup skema ditutup oleh DROP SCHEMA. Skema yang masih berisi objek akan menolak dihapus kecuali Anda menyatakan CASCADE secara eksplisit — dan pernyataan itu menjatuhkan seluruh isinya sekaligus:

sql hapus-skema.sql
-- Bersih-bersih: role uji dipensiunkan karena tidak lagi dibutuhkan.
DROP ROLE petugas_tokomaju;

-- DROP SCHEMA menolak bekerja bila skema masih berisi objek;
-- CASCADE menjatuhkan skema beserta seluruh isinya sekaligus.
DROP SCHEMA inventaris CASCADE;

-- Inventaris lenyap bersama tabel produknya;
-- laporan dan public tetap utuh.
SELECT schemaname, tablename
FROM pg_tables
WHERE schemaname IN ('public', 'inventaris', 'laporan')
ORDER BY schemaname, tablename;

Output yang Dihasilkan:

text
DROP ROLE
DROP SCHEMA
 schemaname |     tablename     
------------+-------------------
 laporan    | penjualan_bulanan
 public     | produk
(2 rows)

DROP ROLE petugas_tokomaju memensiunkan role uji dari cluster, lalu DROP SCHEMA inventaris CASCADE menghapus skema beserta inventaris.produk di dalamnya. Query verifikasi menunjukkan sisa peta: laporan.penjualan_bulanan dan public.produk tetap utuh. Server sesungguhnya mengirimkan daftar objek yang ikut jatuh sebagai NOTICE — pada eksekusi nyata Anda akan melihat baris seperti drop cascades to table inventaris.produk — daftar yang layak dibaca pelan sebelum CASCADE dijalankan pada skema produksi, karena tidak ada lagi tombol undo di baliknya.

Kita kini memegang seluruh perangkat perancangan section ini: entitas dan relasi, normalisasi sampai 3NF, denormalisasi yang sadar biaya, dan skema untuk merapikan ruang nama. Perangkat itu belum pernah dijalankan dari nol dalam satu kali berjalan — dan justru itulah cara terbaik mengujinya. Chapter penutup section ini menyusun skema database perpustakaan mini dari narasi kebutuhan mentah sampai tabel final: enam langkah, satu diagram ER, satu berkas DDL lengkap, dan kueri pembuktian untuk setiap kebutuhan.

Referensi #

Berkas Sumber

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

buat-skema.sql

Unduh berkas
sql
-- Skema memisahkan ranah di dalam satu database:
-- inventaris untuk data gudang, laporan untuk tabel ringkasan manajemen.
CREATE SCHEMA inventaris;
CREATE SCHEMA laporan;

-- Nama objek kini ditulis terkualifikasi: skema.tabel.
CREATE TABLE inventaris.produk (
    kode text PRIMARY KEY,
    nama text NOT NULL,
    stok  integer NOT NULL CHECK (stok >= 0)
);

CREATE TABLE laporan.penjualan_bulanan (
    bulan date PRIMARY KEY,
    total numeric(14, 2) NOT NULL
);

INSERT INTO inventaris.produk (kode, nama, stok) VALUES
    ('P01', 'Buku Tulis',   120),
    ('P02', 'Pulpen Hitam',  85),
    ('P03', 'Penggaris',     40);

INSERT INTO laporan.penjualan_bulanan (bulan, total) VALUES
    (DATE '2026-01-01', 75000.00),
    (DATE '2026-02-01', 35500.00);

-- Dua tabel hidup berdampingan tanpa saling bertabrakan nama.
SELECT kode, nama, stok
FROM inventaris.produk
ORDER BY kode;

SELECT bulan, total
FROM laporan.penjualan_bulanan
ORDER BY bulan;

-- Daftar skema yang terdaftar di database ini, dibaca dari catalog sistem.
SELECT nspname AS skema
FROM pg_namespace
WHERE nspname IN ('public', 'inventaris', 'laporan')
ORDER BY nspname;

hak-akses.sql

Unduh berkas
sql
-- Menyiapkan role biasa untuk menguji hak akses schema public.
DROP ROLE IF EXISTS petugas_tokomaju;
CREATE ROLE petugas_tokomaju LOGIN;

-- Bandingkan dua sisi: superuser postgres dan role baru petugas_tokomaju.
-- USAGE = boleh menyebut objek di skema itu; CREATE = boleh membuat objek baru.
SELECT has_schema_privilege('postgres', 'public', 'USAGE')  AS superuser_usage,
       has_schema_privilege('postgres', 'public', 'CREATE') AS superuser_create,
       has_schema_privilege('petugas_tokomaju', 'public', 'USAGE')  AS petugas_usage,
       has_schema_privilege('petugas_tokomaju', 'public', 'CREATE') AS petugas_create;

hak-public.sql

Unduh berkas
sql
-- Sejak PostgreSQL 15, role biasa tidak lagi boleh membuat tabel
-- di schema public. Percobaan berikut membuktikannya secara langsung:
-- sesi berpura-pura menjadi petugas_tokomaju, lalu mencoba CREATE TABLE.
SET ROLE petugas_tokomaju;

CREATE TABLE public.catatan_uji (id integer);

hapus-skema.sql

Unduh berkas
sql
-- Bersih-bersih: role uji dipensiunkan karena tidak lagi dibutuhkan.
DROP ROLE petugas_tokomaju;

-- DROP SCHEMA menolak bekerja bila skema masih berisi objek;
-- CASCADE menjatuhkan skema beserta seluruh isinya sekaligus.
DROP SCHEMA inventaris CASCADE;

-- Inventaris lenyap bersama tabel produknya;
-- laporan dan public tetap utuh.
SELECT schemaname, tablename
FROM pg_tables
WHERE schemaname IN ('public', 'inventaris', 'laporan')
ORDER BY schemaname, tablename;

katalog-sistem.sql

Unduh berkas
sql
-- pg_catalog tidak pernah tampil di search_path,
-- tetapi isi katalog sistem selalu bisa diakses tanpa kualifikasi.
SHOW search_path;

-- Fungsi sistem seperti pg_typeof ditemukan padahal skema rumahnya
-- (pg_catalog) tidak tertulis di jalur pencarian.
SELECT pg_typeof(1) AS tipe_angka,
       pg_typeof(DATE '2026-01-01') AS tipe_tanggal;

-- Penulisan terkualifikasi memberi hasil yang sama --
-- bukti bahwa rumah aslinya memang pg_catalog.
SELECT pg_catalog.pg_typeof(1) AS tipe_angka_terkualifikasi;

-- pg_tables membaca catalog sistem: tabel kita tercatat lengkap
-- dengan nama skemanya.
SELECT schemaname, tablename
FROM pg_tables
WHERE schemaname IN ('public', 'inventaris', 'laporan')
ORDER BY schemaname, tablename;

resolusi-nama.sql

Unduh berkas
sql
-- Eksperimen resolusi nama: dua tabel bernama sama, produk, di dua skema.
-- public.produk menyimpan harga katalog; inventaris.produk menyimpan stok.
CREATE TABLE public.produk (
    kode  text PRIMARY KEY,
    nama  text NOT NULL,
    harga numeric(12, 2) NOT NULL
);

INSERT INTO public.produk (kode, nama, harga) VALUES
    ('P01', 'Buku Tulis',   4500.00),
    ('P02', 'Pulpen Hitam', 3000.00),
    ('P03', 'Penggaris',    2500.00);

-- search_path bawaan: skema bernama sama dengan pengguna (bila ada),
-- lalu public.
SHOW search_path;

SELECT current_schema();

-- Nama polos "produk" jatuh ke public: kolom harga yang menjawab.
SELECT *
FROM produk
ORDER BY kode;

-- Ganti urutan pencarian: inventaris didahulukan.
SET search_path TO inventaris, public;

SHOW search_path;

SELECT current_schema();

-- Nama polos "produk" kini jatuh ke inventaris: kolom stok yang menjawab.
SELECT *
FROM produk
ORDER BY kode;

-- Cukup public saja di jalur pencarian -- inventaris tidak lagi dijangkau
-- tanpa nama terkualifikasi.
SET search_path TO public;

SELECT current_schema();

SELECT *
FROM produk
ORDER BY kode;

-- Kembalikan nilai bawaan untuk sesi ini.
RESET search_path;

SELECT current_schema();