Migration di chapter sebelumnya menyelesaikan separuh pekerjaan: struktur tabel kini sama di laptop, staging, dan produksi. Separuh sisnya adalah isi — halaman daftar produk yang kita kembangkan butuh barang di dalam tabelnya, form pelanggan butuh beberapa akun untuk diuji, dan laporan butuh angka untuk dihitung. Seeding adalah praktik mengisi database dengan data awal itu, dan meskipun terdengar sepele, ia punya syarat yang sama ketatnya dengan migration: dijalankan berulang kali tanpa menduplikasi baris, hasilnya sama di environment mana pun, dan tidak pernah membocorkan data nyata. Dalam chapter ini kita menulis seed.sql dan menjalankannya sungguhan lewat mode batch, membedakan dua senjata idempotensi (INSERT IGNORE dan ON DUPLICATE KEY UPDATE), lalu melakukan hal yang sama dari dalam aplikasi Python.
Beda Migration dan Seeding #
Kedua disiplin ini sering hidup berdampingan di folder yang sama, tetapi pekerjaannya berbeda arah:
| Aspek | Migration | Seeding |
|---|---|---|
| Yang diubah | struktur — tabel, kolom, index (DDL) | isi — baris data (DML) |
| Kapan dijalankan | sekali per versi, saat skema berubah | kapan pun environment disiapkan atau di-reset |
| Sifat wajib | maju berurutan, tercatat | idempoten, boleh diulang kapan saja |
| Contoh isi | 001_create_produk.sql | seed.sql |
| Ditaruh di | dipicu oleh perubahan kode | dipicu oleh kebutuhan data latihan |
Seeding paling sering dipakai di dua suasana: data referensi — baris yang aplikasi butuh untuk berfungsi (daftar kategori, user admin awal, pengaturan bawaan) yang bahkan ikut ke produksi; dan data latihan — baris fiktif untuk development dan testing yang tidak pernah boleh menyentuh produksi. Bab ini berfokus pada kelompok kedua; kelompok pertama lazim diperlakukan sebagai migration biasa karena lifecycle-nya sama persis.
Menyiapkan Struktur dan Data Awal #
Struktur dari migration kita pinjam kembali dalam bentuk sederhana — dua tabel di belajar_app — dan sekaligus kita muat data awalnya di sesi klien (isinya sama persis dengan seed.sql yang akan kita uji sebentar lagi, sehingga demo berikutnya punya baris untuk dibentrokkan):
DROP DATABASE IF EXISTS belajar_app;
CREATE DATABASE belajar_app;
USE belajar_app;
CREATE TABLE produk (
id INT PRIMARY KEY,
nama VARCHAR(100) NOT NULL,
harga INT NOT NULL,
stok INT NOT NULL DEFAULT 0
);
CREATE TABLE pelanggan (
id INT PRIMARY KEY,
nama VARCHAR(100) NOT NULL,
email VARCHAR(150) NOT NULL UNIQUE
);
INSERT INTO produk (id, nama, harga, stok) VALUES
(1, 'Kopi Toraja', 52000, 12),
(2, 'Teh Melati', 28000, 30),
(3, 'Gula Aren', 32000, 8);
INSERT INTO pelanggan (id, nama, email) VALUES
(1, 'Budi Santoso', '[email protected]'),
(2, 'Siti Aminah', '[email protected]');seed.sql: Data Awal dengan Id Tetap #
Berikut seed.sql — tersimpan di bundle chapter (bagian Berkas Sumber). Dua keputusan desainnya terbaca langsung dari isi file:
-- @notest
-- seed.sql — data awal untuk pengembangan dan testing.
-- Idempoten: id tetap + INSERT IGNORE membuat file aman
-- dijalankan berulang kali tanpa menduplikasi baris.
INSERT IGNORE INTO produk (id, nama, harga, stok) VALUES
(1, 'Kopi Toraja', 52000, 12),
(2, 'Teh Melati', 28000, 30),
(3, 'Gula Aren', 32000, 8);
INSERT IGNORE INTO pelanggan (id, nama, email) VALUES
(1, 'Budi Santoso', '[email protected]'),
(2, 'Siti Aminah', '[email protected]');Keputusan pertama: semua id ditulis tetap. Auto-increment memang praktis untuk data operasional, tetapi untuk seed justru bencana — dua eksekusi file menghasilkan dua set baris dengan id berbeda, dan data uji kita berhenti deterministik. Id tetap membuat “Kopi Toraja selalu bernama id 1” di semua environment, sehingga test bisa mengandalkan id itu.
Keputusan kedua: INSERT IGNORE. Ketika baris dengan id yang sama sudah ada, statement tidak error melainkan melewati baris itu diam-diam — file selesai tanpa kegagalan, tanpa duplikat. Kita buktikan dengan menjalankan file yang sama dua kali berturut-turut, lalu menghitung isi tabelnya:
-- @test-bash
mysql -h 127.0.0.1 -P 3306 -u root belajar_app < content/mysql/integrasi-aplikasi/database-seeding/seed.sql
mysql -h 127.0.0.1 -P 3306 -u root belajar_app < content/mysql/integrasi-aplikasi/database-seeding/seed.sql
mysql -h 127.0.0.1 -P 3306 -u root -t -e "SELECT COUNT(*) AS jumlah_produk FROM belajar_app.produk; SELECT COUNT(*) AS jumlah_pelanggan FROM belajar_app.pelanggan;"Output yang Dihasilkan:
+---------------+
| jumlah_produk |
+---------------+
| 3 |
+---------------+
+------------------+
| jumlah_pelanggan |
+------------------+
| 2 |
+------------------+Mode batch memang senyap untuk INSERT — dua baris hasil di akhir berasal dari SELECT COUNT(*) kita. Angka 3 dan 2 setelah file dijalankan dua kali adalah bukti idempotensi: setiap INSERT IGNORE menemukan baris dengan id yang sama sudah ada, lalu melewatkannya, sehingga eksekusi kedua tidak menambah satu baris pun. Pada database kosong hasil migration, file inilah yang mengisi tabel — dan pada database yang sudah terisi seperti milik kita, file yang sama tidak mengubah apa-apa. Satu file, dua situasi, dua hasil yang sama-sama benar.
INSERT IGNORE versus ON DUPLICATE KEY UPDATE #
INSERT IGNORE punya saudara yang tujuannya berbeda. IGNORE melewatkan baris bentrok; ON DUPLICATE KEY UPDATE justru memperbarui baris bentrok dengan nilai baru. Kita uji keduanya di klien pada tabel yang barusan terisi:
INSERT IGNORE INTO produk (id, nama, harga, stok) VALUES
(1, 'Kopi Toraja Palsu', 99000, 99);
SELECT nama, harga, stok FROM produk WHERE id = 1;Output yang Dihasilkan:
Query OK, 0 rows affected, 1 warning (0.00 sec)
+-------------+-------+------+
| nama | harga | stok |
+-------------+-------+------+
| Kopi Toraja | 52000 | 12 |
+-------------+-------+------+
1 row in set (0.00 sec)INSERT INTO produk (id, nama, harga, stok) VALUES
(1, 'Kopi Toraja Palsu', 99000, 99)
ON DUPLICATE KEY UPDATE stok = stok + 5;
SELECT nama, harga, stok FROM produk WHERE id = 1;Output yang Dihasilkan:
Query OK, 2 rows affected (0.00 sec)
+-------------+-------+------+
| nama | harga | stok |
+-------------+-------+------+
| Kopi Toraja | 52000 | 17 |
+-------------+-------+------+
1 row in set (0.00 sec)Hasil pertama: Query OK, 0 rows affected, 1 warning dan baris Kopi Toraja tidak tersentuh — INSERT IGNORE mengubah error duplikat (1062) menjadi warning senyap (isinya bisa kita intip kapan saja dengan SHOW WARNINGS), lalu melewati baris bentrok itu sepenuhnya, termasuk nilai-nilainya. Hasil kedua: baris tetap bernama Kopi Toraja, tetapi stok bertambah 5 (dari 12 menjadi 17) — ON DUPLICATE KEY UPDATE memakai kunci bentrok sebagai alamat update, bukan insert. Aturan pemilihannya: data latihan yang cukup “ada atau tidak ada” → INSERT IGNORE; data referensi yang harus selalu memantulkan nilai terbaru (misalnya pengaturan bawaan) → ON DUPLICATE KEY UPDATE. Pesan Query OK, 2 rows affected pada kasus kedua juga khas: MySQL menghitung satu baris untuk percobaan insert dan satu untuk update yang terjadi.
Seeding dari Aplikasi: seed.py #
File SQL bukan satu-satunya pintu. Seeding dari kode aplikasi masuk akal saat datanya dinamis — diambil dari sumber lain, bergantung tanggal, atau perlu logika kondisional. Berikut seed.py yang memakai executemany + INSERT IGNORE, lalu melaporkan berapa baris yang benar-benar masuk:
"""seed.py — seeding data awal lewat aplikasi dengan PyMySQL.
Jalankan dari folder chapter: python3 seed.py
Idempoten: INSERT IGNORE melewati baris yang id-nya sudah ada.
Port default 3306; timpa lewat variabel lingkungan DB_PORT.
"""
import os
import pymysql
port = int(os.environ.get("DB_PORT", "3306"))
produk_tambahan = [
(4, "Susu Kental", 38000, 15),
(5, "Kopi Robusta", 48000, 20),
(6, "Teh Hijau", 30000, 25),
]
pelanggan_tambahan = [
(3, "Rina Wijaya", "[email protected]"),
(4, "Dedi Kurniawan", "[email protected]"),
]
with pymysql.connect(
host="127.0.0.1",
user="root",
password="",
database="belajar_app",
port=port,
) as koneksi:
with koneksi.cursor() as kursor:
kursor.executemany(
"INSERT IGNORE INTO produk (id, nama, harga, stok) VALUES (%s, %s, %s, %s)",
produk_tambahan,
)
print("produk baru masuk :", kursor.rowcount)
kursor.executemany(
"INSERT IGNORE INTO pelanggan (id, nama, email) VALUES (%s, %s, %s)",
pelanggan_tambahan,
)
print("pelanggan baru masuk:", kursor.rowcount)
koneksi.commit()
with koneksi.cursor() as kursor:
kursor.execute("SELECT COUNT(*) FROM produk")
total_produk = kursor.fetchone()[0]
kursor.execute("SELECT COUNT(*) FROM pelanggan")
total_pelanggan = kursor.fetchone()[0]
print(f"isi tabel sekarang: {total_produk} produk, {total_pelanggan} pelanggan")Menjalankannya dua kali — idempotensi dari sisi aplikasi harus sama seperti dari sisi file SQL (dengan DB_PORT seperti biasa; di komputer Anda cukup python3 seed.py):
-- @test-bash
DB_PORT=3306 python3 content/mysql/integrasi-aplikasi/database-seeding/seed.py
DB_PORT=3306 python3 content/mysql/integrasi-aplikasi/database-seeding/seed.pyOutput yang Dihasilkan:
produk baru masuk : 3
pelanggan baru masuk: 2
isi tabel sekarang: 6 produk, 4 pelanggan
produk baru masuk : 0
pelanggan baru masuk: 0
isi tabel sekarang: 6 produk, 4 pelangganEksekusi pertama memasukkan 3 produk dan 2 pelanggan; eksekusi kedua melaporkan 0 dan 0 — rowcount dari INSERT IGNORE hanya menghitung baris yang benar-benar masuk. Total tabel tetap 6 produk, 4 pelanggan: tiga produk + dua pelanggan dari seed.sql, ditambah kelompok tambahan dari seed.py. Perhatikan juga urutan di dalam file: koneksi.commit() dipanggil sebelum query COUNT(*) dijalankan, dan seluruh isi blok with akan di-rollback bila commit terlewat — perilaku context manager PyMySQL yang kita bahas di chapter Python.
Seeding untuk Testing #
Data uji otomatis (fixture) menuntut satu sifat ekstra: deterministik penuh. Test yang mengandalkan “produk paling mahal” harus selalu menemukan produk yang sama dengan harga yang sama — jadi seed untuk testing tidak boleh memakai NOW(), RAND(), atau id auto-increment; tanggal ditulis literal ('2026-01-15'), id ditulis tetap, dan setiap test mengembalikan database ke kondisi seed sebelum berjalan (truncate lalu seed ulang, atau transaksi yang di-rollback). Aturan kebalikannya juga berlaku: bila test gagal, orang yang membacanya harus yakin penyebabnya kode, bukan data seed yang berubah sendiri.
Seeding per Environment #
Di environment mana seed boleh jalan? Praktik umumnya: dev dan staging bebas di-seed ulang kapan saja (bahkan sering otomatis setiap test suite berjalan); produksi selektif — hanya data referensi yang diperlukan aplikasi (kategori, role, akun admin bawaan) yang boleh ikut, dan tetap lewat jalur migration yang ter-review. File seed.sql untuk dev jangan pernah diarahkan ke database produksi: file itu tidak tahu environment-nya, yang tahu adalah kita — dan sekali salah arah, data fiktif bercampur dengan data nyata.
Membersihkan Latihan #
-- @test-bash
mysql -h 127.0.0.1 -P 3306 -u root -e "DROP DATABASE IF EXISTS belajar_app;"Referensi #
- INSERT Statement — MySQL 8.4 Reference Manual
- INSERT … ON DUPLICATE KEY UPDATE Statement — MySQL 8.4 Reference Manual
Struktur terjaga migration, isi terjaga seeding — satu wilayah terakhir yang memisahkan aplikasi yang sehat dari yang rapuh: bagaimana ia bersikap saat database menolak, gagal, atau mati. Chapter penutup section ini, Menangani Database Errors, memetakan kode-kode error yang paling sering muncul dan pola penanganannya satu per satu.
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
seed.py
Unduh berkas"""seed.py — seeding data awal lewat aplikasi dengan PyMySQL.
Jalankan dari folder chapter: python3 seed.py
Idempoten: INSERT IGNORE melewati baris yang id-nya sudah ada.
Port default 3306; timpa lewat variabel lingkungan DB_PORT.
"""
import os
import pymysql
port = int(os.environ.get("DB_PORT", "3306"))
produk_tambahan = [
(4, "Susu Kental", 38000, 15),
(5, "Kopi Robusta", 48000, 20),
(6, "Teh Hijau", 30000, 25),
]
pelanggan_tambahan = [
(3, "Rina Wijaya", "[email protected]"),
(4, "Dedi Kurniawan", "[email protected]"),
]
with pymysql.connect(
host="127.0.0.1",
user="root",
password="",
database="belajar_app",
port=port,
) as koneksi:
with koneksi.cursor() as kursor:
kursor.executemany(
"INSERT IGNORE INTO produk (id, nama, harga, stok) VALUES (%s, %s, %s, %s)",
produk_tambahan,
)
print("produk baru masuk :", kursor.rowcount)
kursor.executemany(
"INSERT IGNORE INTO pelanggan (id, nama, email) VALUES (%s, %s, %s)",
pelanggan_tambahan,
)
print("pelanggan baru masuk:", kursor.rowcount)
koneksi.commit()
with koneksi.cursor() as kursor:
kursor.execute("SELECT COUNT(*) FROM produk")
total_produk = kursor.fetchone()[0]
kursor.execute("SELECT COUNT(*) FROM pelanggan")
total_pelanggan = kursor.fetchone()[0]
print(f"isi tabel sekarang: {total_produk} produk, {total_pelanggan} pelanggan")
seed.sql
Unduh berkas-- seed.sql — data awal untuk pengembangan dan testing.
-- Idempoten: id tetap + INSERT IGNORE membuat file aman
-- dijalankan berulang kali tanpa menduplikasi baris.
INSERT IGNORE INTO produk (id, nama, harga, stok) VALUES
(1, 'Kopi Toraja', 52000, 12),
(2, 'Teh Melati', 28000, 30),
(3, 'Gula Aren', 32000, 8);
INSERT IGNORE INTO pelanggan (id, nama, email) VALUES
(1, 'Budi Santoso', '[email protected]'),
(2, 'Siti Aminah', '[email protected]');