Migrasi meninggalkan database yang rapi tetapi kosong: tabel kategori tanpa kategori, tabel pelanggan tanpa pelanggan, halaman utama aplikasi tampak rusak karena data acuannya belum ada. Database seeding adalah proses mengisi data awal itu secara terprogram — dijalankan berulang kali setiap kali lingkungan dibangun ulang, oleh siapa pun, di mesin mana pun, dengan hasil yang sama. Dua kata kunci itu adalah seluruh kontraknya: berulang berarti seed harus idempoten (jalankan dua kali, datanya tetap satu), dan hasil yang sama berarti seed harus deterministik (bug yang Anda temui hari ini harus bisa direproduksi besok). Chapter ini membangun seed untuk Toko Maju Jaya dalam tiga bentuk — data acuan dengan ON CONFLICT, data massal dengan generate_series, dan skrip seed dengan psycopg — lalu menutup dengan aturan seed per lingkungan.

Migrasi, Seeding, dan Fixture #

Tiga kata yang sering tertukar karena ketiganya “mengisi database”, padahal membedakan ketiganya menentukan di mana sebuah berkas harus hidup:

AspekMigrationSeedingFixture
Mengisi apaStruktur (tabel, index, constraint)Data awal lingkungan (acuan + contoh)Data per test case
DijalankanSekali per versi, maju sajaSetiap kali lingkungan dibangunSebelum tiap test berjalan
TargetSemua lingkunganDev, test, demoLingkungan test otomatis
Dikelola olehRunner migrasiSkrip seed / task terpisahFramework pengujian

Data acuan (reference data) seperti daftar kategori atau tabel kode pos menempati wilayah abu-abu: ia wajib ada di produksi juga. Praktik yang sehat adalah memasukkan data acuan ke dalam migrasi (ia bagian dari kontrak aplikasi), sedangkan seeding mengisi data contoh — pelanggan palsu, pesanan palsu — yang hanya berarti di luar produksi. Chapter Database Migration sudah menangani struktur; chapter ini fokus pada sisanya.

Aturan Determinisme Seed #

Seed yang tidak deterministik merusak dua hal sekaligus: pengujian dan kolaborasi. Bila setiap anggota tim menjalankan seed dan mendapat data berbeda, bug yang dilaporkan satu orang tidak bisa direproduksi orang lain. Tiga aturan menjaga seed tetap deterministik:

  1. Data tetap ditulis sebagai literal — nama Budi, harga 15000, tanggal DATE '2026-01-15', bukan hasil now() atau current_date.
  2. Nilai acak diawali setseed() — satu panggilan SELECT setseed(0.42); di awal berkas membuat deret random() berikutnya sama setiap kali dijalankan ulang.
  3. Kunci utama ditentukan manual — jangan bergantung pada urutan identity yang implisit bila baris seed akan direferensikan oleh data seed lain.

Pendekatan yang sama dipakai di chapter Menguji Query SQL , di mana data uji harus stabil agar hasil test bisa dipercaya. Pola fixture di sana memakai data kecil per test; seed di sini memakai satu dataset untuk seluruh lingkungan.

Data Acuan dengan ON CONFLICT #

Pola paling ringan: INSERT literal biasa ditambah klausa ON CONFLICT ... DO NOTHING, sehingga seed bisa dijalankan berulang tanpa error dan tanpa duplikat. Berkas berikut sengaja menjalankan seed yang sama dua kali dalam satu sesi untuk membuktikan keduanya:

sql seed-tetap.sql
-- Seed data acuan Toko Maju Jaya: idempoten dengan ON CONFLICT.
CREATE TABLE kategori (
    kode text PRIMARY KEY,
    nama text NOT NULL
);

-- Seed pertama: tiga baris masuk.
INSERT INTO kategori (kode, nama) VALUES
    ('ATK', 'Alat Tulis Kantor'),
    ('MKN', 'Makanan'),
    ('MNM', 'Minuman')
ON CONFLICT (kode) DO NOTHING;

-- Seed yang sama dijalankan LAGI (misalnya skrip dijalankan ulang
-- setelah deploy): ketiga baris sudah ada, tidak ada yang masuk.
INSERT INTO kategori (kode, nama) VALUES
    ('ATK', 'Alat Tulis Kantor'),
    ('MKN', 'Makanan'),
    ('MNM', 'Minuman')
ON CONFLICT (kode) DO NOTHING;

SELECT kode, nama FROM kategori ORDER BY kode;

Output yang Dihasilkan:

text
CREATE TABLE
INSERT 0 3
INSERT 0 0
 kode |       nama        
------+-------------------
 ATK  | Alat Tulis Kantor
 MKN  | Makanan
 MNM  | Minuman
(3 rows)

Bukti idempotensinya ada pada dua tag INSERT: seed pertama melaporkan INSERT 0 3, seed kedua melaporkan INSERT 0 0 — nol baris masuk karena ketiga kode sudah ada, dan SELECT penutup tetap menampilkan tiga baris. ON CONFLICT (kode) menyasar constraint primary key pada kolom kode; varian tanpa target, ON CONFLICT DO NOTHING saja, menangkap pelanggaran constraint unik mana pun.

Pola alternatif untuk lingkungan pengembangan murni adalah truncate + reinsert: kosongkan tabel lalu isi ulang dari awal, sehingga state selalu segar. Bentuknya hanya dua pernyataan:

sql
TRUNCATE tabel_seed;
INSERT INTO tabel_seed (...) VALUES (...);

Pola ini membuat data selalu identik dengan definisi seed, tetapi menghapus perubahan manual apa pun — cocok untuk database mainan, berbahaya bila disalahpahami sebagai pola untuk database berisi kerja nyata.

Data Pengembangan dengan generate_series #

Untuk volume — 50 produk, 1000 pesanan — menulis literal satu per satu tidak realistis. generate_series mengisi banyak baris dari satu rumus, dan kombinasi dengan setseed membuat bagian acaknya tetap deterministik:

-- Seed data pengembangan: data palsu massal dengan generate_series.
-- setseed membuat random() deterministik: nilai acak yang sama setiap
-- kali berkas ini dijalankan ulang.
SELECT setseed(0.42);

CREATE TABLE produk_dev (
    id    integer PRIMARY KEY,
    nama  text NOT NULL,
    harga integer NOT NULL,
    stok  integer NOT NULL
);

-- Nama dan harga dari rumus tetap; stok satu-satunya kolom acak.
INSERT INTO produk_dev (id, nama, harga, stok)
SELECT gs,
       'Produk-' || lpad(gs::text, 3, '0'),
       ((gs % 20) + 1) * 1000,
       (random() * 10)::int + 1
FROM generate_series(1, 50) AS gs;

SELECT count(*) AS total_produk,
       min(harga) AS harga_termurah,
       max(harga) AS harga_termahal
FROM produk_dev;

SELECT id, nama, harga, stok
FROM produk_dev
ORDER BY id
LIMIT 5;

Output yang Dihasilkan:

text
 setseed 
---------
 
(1 row)

CREATE TABLE
INSERT 0 50
 total_produk | harga_termurah | harga_termahal 
--------------+----------------+----------------
           50 |           1000 |          20000
(1 row)

 id |    nama    | harga | stok 
----+------------+-------+------
  1 | Produk-001 |  2000 |    8
  2 | Produk-002 |  3000 |    5
  3 | Produk-003 |  4000 |   10
  4 | Produk-004 |  5000 |    2
  5 | Produk-005 |  6000 |    2
(5 rows)

Mari kita bedah rumus pada baris SELECT di dalam INSERT:

  1. generate_series(1, 50) AS gs — menghasilkan bilangan 1 sampai 50, satu baris per bilangan; seluruh kolom lain diturunkan darinya.
  2. 'Produk-' || lpad(gs::text, 3, '0') — nama berurutan rapi: Produk-001 sampai Produk-050, berkat lpad yang menambahkan nol di depan.
  3. ((gs % 20) + 1) * 1000 — harga berulang tiap 20 produk antara 1000 sampai 20000, cukup variatif untuk menguji filter rentang harga.
  4. (random() * 10)::int + 1 — stok acak 1 sampai 10; deterministik hanya karena setseed(0.42) dipanggil di awal berkas — jalankan berkas ini kapan pun, lima baris pertama selalu menampilkan stok 8, 5, 10, 2, 2.

Satu artefak output yang perlu dibaca benar: panggilan SELECT setseed(0.42) mengembalikan satu baris kosong (setseed mengembalikan void), sehingga output diawali tabel satu kolom tanpa nilai — perilaku normal, bukan error.

Skrip Seed dengan psycopg #

Bentuk ketiga adalah skrip seed sebagai kode aplikasi — struktur yang sama dengan runner migrasi pada chapter sebelumnya, tetapi mengisi data. Skrip berikut mendefinisikan dataset sekali sebagai daftar Python, menjalankan fungsinya dua kali, dan memverifikasi bahwa jumlah baris tidak berubah:

python seed-psycopg.py
# Skrip seed dengan psycopg: data tetap, idempoten, bisa dijalankan
# berulang kapan saja tanpa menimbulkan duplikat.
import psycopg

PELANGGAN = [
    (1, "Budi", "[email protected]"),
    (2, "Siti", "[email protected]"),
    (3, "Ani", "[email protected]"),
]

conn = psycopg.connect()
cur = conn.cursor()


def jalankan_seed():
    cur.execute("""
        CREATE TABLE IF NOT EXISTS pelanggan_seed (
            id    integer PRIMARY KEY,
            nama  text NOT NULL,
            email text NOT NULL UNIQUE
        )
    """)
    # ON CONFLICT DO NOTHING tanpa target menangkap pelanggaran
    # constraint apa pun (primary key maupun unique) dan melewatkan
    # baris itu.
    cur.executemany(
        """
        INSERT INTO pelanggan_seed (id, nama, email)
        VALUES (%s, %s, %s)
        ON CONFLICT DO NOTHING
        """,
        PELANGGAN,
    )
    conn.commit()


jalankan_seed()
cur.execute("SELECT count(*) FROM pelanggan_seed")
print("setelah seed pertama :", cur.fetchone()[0], "pelanggan")

jalankan_seed()
cur.execute("SELECT count(*) FROM pelanggan_seed")
print("setelah seed kedua   :", cur.fetchone()[0], "pelanggan")

cur.execute("SELECT id, nama, email FROM pelanggan_seed ORDER BY id")
for id_pelanggan, nama, email in cur.fetchall():
    print(f"{id_pelanggan} {nama} <{email}>")

cur.close()
conn.close()

Output yang Dihasilkan:

text
setelah seed pertama : 3 pelanggan
setelah seed kedua   : 3 pelanggan
1 Budi <[email protected]>
2 Siti <[email protected]>
3 Ani <[email protected]>

Dua baris pertama output adalah bukti kontrak idempoten: seed pertama menghasilkan 3 pelanggan, seed kedua tetap 3 pelanggan. Beberapa keputusan desain pada skrip ini layak dicatat: dataset hidup sebagai konstanta PELANGGAN di atas sehingga mudah direview; CREATE TABLE IF NOT EXISTS membuat skrip aman pada database yang benar-benar kosong; dan ON CONFLICT DO NOTHING tanpa target melindungi dari pelanggaran id maupun email sekaligus. Untuk dataset yang lebih besar, ganti isi PELANGGAN dengan pembacaan berkas CSV atau hasil generate_series — kerangkanya tidak berubah.

Seed per Lingkungan #

Isi seed harus berbeda mengikuti tujuan lingkungannya — satu dataset untuk semua lingkungan adalah sumber kekacauan klasik:

LingkunganIsi seedUkuran wajar
PengembanganData palsu lengkap: kategori, produk massal, pelanggan contoh, pesanan berulangRibuan baris
Test otomatisMinimal: satu dataset kecil yang menutup cabang logika yang diujiPuluhan baris
Staging/demoSalinan data palsu produksi (bentuk sama, volume mirip)Mendekati produksi
ProduksiHanya data bootstrap: akun admin awal, data acuan — tidak ada data palsuSedikit dan diawasi ketat

Sumber Data Seed #

Terakhir, dari mana isi seed berasal — pilihan menentukan seberapa jauh seed bisa menskalakan:

SumberCocok untukCatatan
INSERT literalData acuan, dataset kecilPaling mudah direview; semua nilai terlihat di diff
generate_series + rumusData massal berpolaDeterministik penuh; bentuk data seragam
COPY / \copy dari berkasImport dataset besar yang sudah adaTercepat untuk jutaan baris; berkas ikut masuk repo bila kecil
Pustaka fakerData palsu yang menyerupai nyata (nama, alamat)Acak default — kunci determinismenya harus disetel (seed tetap) bila hasil harus reproducible

Skema kini terbangun lewat migrasi dan terisi lewat seed — dua fondasi yang membuat setiap lingkungan bisa dibangun ulang dari nol dengan hasil identik. Namun begitu aplikasi mulai berjalan, kenyataan berikutnya menampar: pengguna mengisi email yang sudah terdaftar, query menyentuh tabel yang tidak ada, koneksi terputus di tengah operasi. Aplikasi yang sehat tidak runtuh oleh error itu — ia menangkapnya, membaca kode SQLSTATE-nya, dan memutuskan reaksi yang tepat. Itu topik chapter berikutnya, Menangani Database Error.

Referensi #

Berkas Sumber

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

seed-generate-series.sql

Unduh berkas
sql
-- Seed data pengembangan: data palsu massal dengan generate_series.
-- setseed membuat random() deterministik: nilai acak yang sama setiap
-- kali berkas ini dijalankan ulang.
SELECT setseed(0.42);

CREATE TABLE produk_dev (
    id    integer PRIMARY KEY,
    nama  text NOT NULL,
    harga integer NOT NULL,
    stok  integer NOT NULL
);

-- Nama dan harga dari rumus tetap; stok satu-satunya kolom acak.
INSERT INTO produk_dev (id, nama, harga, stok)
SELECT gs,
       'Produk-' || lpad(gs::text, 3, '0'),
       ((gs % 20) + 1) * 1000,
       (random() * 10)::int + 1
FROM generate_series(1, 50) AS gs;

SELECT count(*) AS total_produk,
       min(harga) AS harga_termurah,
       max(harga) AS harga_termahal
FROM produk_dev;

SELECT id, nama, harga, stok
FROM produk_dev
ORDER BY id
LIMIT 5;

seed-psycopg.py

Unduh berkas
python
# Skrip seed dengan psycopg: data tetap, idempoten, bisa dijalankan
# berulang kapan saja tanpa menimbulkan duplikat.
import psycopg

PELANGGAN = [
    (1, "Budi", "[email protected]"),
    (2, "Siti", "[email protected]"),
    (3, "Ani", "[email protected]"),
]

conn = psycopg.connect()
cur = conn.cursor()


def jalankan_seed():
    cur.execute("""
        CREATE TABLE IF NOT EXISTS pelanggan_seed (
            id    integer PRIMARY KEY,
            nama  text NOT NULL,
            email text NOT NULL UNIQUE
        )
    """)
    # ON CONFLICT DO NOTHING tanpa target menangkap pelanggaran
    # constraint apa pun (primary key maupun unique) dan melewatkan
    # baris itu.
    cur.executemany(
        """
        INSERT INTO pelanggan_seed (id, nama, email)
        VALUES (%s, %s, %s)
        ON CONFLICT DO NOTHING
        """,
        PELANGGAN,
    )
    conn.commit()


jalankan_seed()
cur.execute("SELECT count(*) FROM pelanggan_seed")
print("setelah seed pertama :", cur.fetchone()[0], "pelanggan")

jalankan_seed()
cur.execute("SELECT count(*) FROM pelanggan_seed")
print("setelah seed kedua   :", cur.fetchone()[0], "pelanggan")

cur.execute("SELECT id, nama, email FROM pelanggan_seed ORDER BY id")
for id_pelanggan, nama, email in cur.fetchall():
    print(f"{id_pelanggan} {nama} <{email}>")

cur.close()
conn.close()

seed-tetap.sql

Unduh berkas
sql
-- Seed data acuan Toko Maju Jaya: idempoten dengan ON CONFLICT.
CREATE TABLE kategori (
    kode text PRIMARY KEY,
    nama text NOT NULL
);

-- Seed pertama: tiga baris masuk.
INSERT INTO kategori (kode, nama) VALUES
    ('ATK', 'Alat Tulis Kantor'),
    ('MKN', 'Makanan'),
    ('MNM', 'Minuman')
ON CONFLICT (kode) DO NOTHING;

-- Seed yang sama dijalankan LAGI (misalnya skrip dijalankan ulang
-- setelah deploy): ketiga baris sudah ada, tidak ada yang masuk.
INSERT INTO kategori (kode, nama) VALUES
    ('ATK', 'Alat Tulis Kantor'),
    ('MKN', 'Makanan'),
    ('MNM', 'Minuman')
ON CONFLICT (kode) DO NOTHING;

SELECT kode, nama FROM kategori ORDER BY kode;