Arsitektur zona pada chapter sebelumnya baru benar-benar terasa ketika kedua ujungnya dibangun: zona raw yang menyimpan data persis apa adanya, dan zona curated yang menjanjikan data bersih kepada konsumen. Di antara keduanya ada pekerjaan yang tidak kelihatan dari luar: memvalidasi setiap baris, menolak yang bermasalah, dan mengubah yang lolos menjadi Parquet. Pada chapter ini kita membangun alur lengkap tersebut dengan pyarrow, memakai data transaksi toko online sebagai bahan uji.

Zona Raw: Simpan Apa Adanya #

Zona raw menyimpan berkas dengan format dan isi yang persis sama dengan saat berangkat dari sumber. Tidak ada pembersihan, tidak ada pembuangan kolom, tidak ada konversi tipe. Alasannya bukan kemalasan, melainkan tiga kebutuhan nyata:

  • Reprocess: ketika logika transformasi ternyata salah, kita mengolah ulang dari raw dan memperbaiki seluruh hasil turunannya tanpa meminta sumber mengirim data lagi.
  • Audit: saat terjadi sengketa angka, berkas asli di raw menjadi bukti yang tidak bisa diragukan karena tidak pernah diubah.
  • Skema baru: kebutuhan analitik baru bisa membaca ulang data lama dengan definisi terbaru — prinsip schema-on-read dari chapter pertama.

Konsekuensi desainnya: berkas di raw ditulis sekali lalu tidak pernah disentuh lagi (immutable), dan setiap berkas diberi nama serta lokasi yang mencerminkan asal-usulnya, misalnya raw/transaksi/tanggal=2026-01-15/pesanan-20260115.jsonl. Kebijakan retensi menentukan berapa lama berkas mentah disimpan — data transaksi mungkin wajib lima tahun karena aturan perpajakan, sedangkan log internal bisa dihapus setelah 90 hari.

Zona Curated: Kontrak untuk Konsumen #

Zona curated adalah hasil akhir yang dijanjikan ke konsumen: baris sudah tervalidasi, tipe kolom sudah tetap, dan strukturnya stabil dalam jangka panjang. Kontrak semacam ini biasa ditulis sebagai data contract: daftar kolom bertipe, arti setiap kolom, dan aturan kualitas yang dijamin. Konsumen — analis, data scientist, hingga dashboard — boleh membangun pekerjaan di atasnya tanpa takut struktur berubah diam-diam; jika memang harus berubah, perubahan diumumkan lebih dulu dengan jeda waktu yang disepakati.

Format penyimpanan yang mendominasi zona curated adalah Parquet: kolom tersimpan berdekatan sehingga query hanya membaca kolom yang dipakai, dan kompresi jauh lebih baik daripada JSON. Spesifikasi formatnya terdokumentasi di dokumentasi Parquet , sementara API Python untuk menulis dan membacanya tersedia di dokumentasi PyArrow .

Praktik: JSONL Mentah menjadi Parquet #

Berikut alur lengkap dalam satu skrip: dua berkas JSONL ditulis ke zona raw (memuat beberapa baris sengaja rusak), lalu setiap baris divalidasi, baris bermasalah dikarantina beserta alasannya, dan baris yang lolos ditulis ke zona curated sebagai Parquet per partisi tanggal:

python
import json
import pathlib
import shutil

import pyarrow as pa
import pyarrow.parquet as pq

BASE = pathlib.Path(__file__).resolve().parent / "zona"
shutil.rmtree(BASE, ignore_errors=True)
RAW = BASE / "raw" / "transaksi"
CURATED = BASE / "curated" / "transaksi"

# --- Tahap 1: ingestion - zona raw menyimpan berkas apa adanya ---
RAW.joinpath("tanggal=2026-01-15").mkdir(parents=True)
RAW.joinpath("tanggal=2026-01-16").mkdir(parents=True)

RAW.joinpath("tanggal=2026-01-15", "pesanan-20260115.jsonl").write_text(
    '{"id": 9001, "waktu": "2026-01-15 09:12", "pelanggan": "Budi", "produk": "Kopi Gayo 250g", "jumlah": 2, "harga": 45000, "total": 90000}\n'
    '{"id": 9002, "waktu": "2026-01-15 10:05", "pelanggan": "Siti", "produk": "Teh Melati 100g", "jumlah": 1, "harga": 16000, "total": 16000}\n'
    '{"id": 9003, "waktu": "2026-01-15 11:40", "pelanggan": "Dewi", "produk": "Gula Aren 500g", "jumlah": 3, "harga": 12000, "total": 36000}\n'
    '{"id": 9004, "waktu": "2026-01-15 13:02", "pelanggan": "Budi", "produk": "Kopi Toraja 200g", "jumlah": 1, "harga": 38000, "total": 34000}\n'
    '{"id": 9005, "waktu": "2026-01-15 15:44", "pelanggan": "Siti", "produk": "Susu Kental 350g", "jumlah": 2, "harga": 15000}\n'
    '{"id": 9006, "waktu": "2026-01-15 21:10", "pelanggan": "Dewi", "produk": "Kopi Robusta 1kg", "jumlah": 1, "harga": 95000, "total": 95000}\n'
    'tidak-valid-baris\n'
)
RAW.joinpath("tanggal=2026-01-16", "pesanan-20260116.jsonl").write_text(
    '{"id": 9010, "waktu": "2026-01-16 08:20", "pelanggan": "Siti", "produk": "Kopi Arabika 200g", "jumlah": 2, "harga": 52000, "total": 104000}\n'
    '{"id": 9011, "waktu": "2026-01-16 09:15", "pelanggan": "Budi", "produk": "Teh Hijau 100g", "jumlah": 1, "harga": 18000, "total": 18000}\n'
    '{"id": 9012, "waktu": "2026-01-16 10:30", "pelanggan": "Dewi", "produk": "Kopi Gayo 250g", "jumlah": 0, "harga": 45000, "total": 0}\n'
    '{"id": 9013, "waktu": "2026-01-16 12:00", "pelanggan": "Rina", "produk": "Cangkir Keramik", "jumlah": 1, "harga": 55000, "total": 55000}\n'
    '{"id": 9014, "waktu": "2026-01-16 14:45", "pelanggan": "Budi", "produk": "Filter Kertas 100pcs", "jumlah": 2, "harga": 12000, "total": 24000}\n'
    '{"id": 9015, "waktu": "2026-01-16 18:22", "pelanggan": "Siti", "produk": "Kopi Liberika 150g", "jumlah": 1, "harga": 60000, "total": 60000}\n'
)

# --- Tahap 2: validasi baris per baris ---
WAJIB = ("id", "waktu", "pelanggan", "produk", "jumlah", "harga", "total")
karantina = []
valid = []

for berkas in sorted(RAW.rglob("*.jsonl")):
    tanggal = berkas.parent.name.split("=")[1]
    for nomor, baris in enumerate(berkas.read_text().splitlines(), start=1):
        try:
            data = json.loads(baris)
        except json.JSONDecodeError:
            karantina.append((tanggal, nomor, "bukan JSON valid"))
            continue
        hilang = [k for k in WAJIB if k not in data]
        if hilang:
            karantina.append((tanggal, nomor, f"kolom hilang: {hilang}"))
        elif data["jumlah"] < 1:
            karantina.append((tanggal, nomor, "jumlah di bawah 1"))
        elif data["total"] != data["jumlah"] * data["harga"]:
            karantina.append((tanggal, nomor, "total tidak konsisten"))
        else:
            valid.append(data)

# --- Tahap 3: tulis zona curated sebagai Parquet per partisi tanggal ---
per_tanggal = {}
for data in valid:
    per_tanggal.setdefault(data["waktu"][:10], []).append(data)

skema = pa.schema([
    ("id", pa.int64()), ("waktu", pa.string()), ("pelanggan", pa.string()),
    ("produk", pa.string()), ("jumlah", pa.int64()),
    ("harga", pa.int64()), ("total", pa.int64()),
])
for tanggal, baris_hari in sorted(per_tanggal.items()):
    folder = CURATED / f"tanggal={tanggal}"
    folder.mkdir(parents=True)
    tabel = pa.Table.from_pylist(baris_hari, schema=skema)
    pq.write_table(tabel, folder / "part-00000.parquet")

# --- Ringkasan hasil ---
print("zona raw:", [str(p.relative_to(BASE)) for p in sorted(RAW.rglob("*")) if p.is_file()])
for tanggal, nomor, alasan in karantina:
    print(f"karantina: {tanggal} baris {nomor}: {alasan}")
for berkas in sorted(CURATED.rglob("*.parquet")):
    tabel = pq.read_table(berkas)
    print(f"curated: {berkas.relative_to(BASE)} -> {tabel.num_rows} baris, "
          f"{berkas.stat().st_size} byte")
tabel_cek = pq.read_table(CURATED)
print("total baris curated:", tabel_cek.num_rows)
print("contoh baris:", tabel_cek.slice(0, 1).to_pylist()[0])

Output yang Dihasilkan:

text
zona raw: ['raw/transaksi/tanggal=2026-01-15/pesanan-20260115.jsonl', 'raw/transaksi/tanggal=2026-01-16/pesanan-20260116.jsonl']
karantina: 2026-01-15 baris 4: total tidak konsisten
karantina: 2026-01-15 baris 5: kolom hilang: ['total']
karantina: 2026-01-15 baris 7: bukan JSON valid
karantina: 2026-01-16 baris 3: jumlah di bawah 1
curated: curated/transaksi/tanggal=2026-01-15/part-00000.parquet -> 4 baris, 2352 byte
curated: curated/transaksi/tanggal=2026-01-16/part-00000.parquet -> 5 baris, 2404 byte
total baris curated: 9
contoh baris: {'id': 9001, 'waktu': '2026-01-15 09:12', 'pelanggan': 'Budi', 'produk': 'Kopi Gayo 250g', 'jumlah': 2, 'harga': 45000, 'total': 90000, 'tanggal': '2026-01-15'}

Mari kita telaah baris-baris kode di atas:

  1. Baris 36 (WAJIB): tuple ini adalah bentuk paling sederhana dari kontrak data — daftar kolom yang wajib ada pada setiap baris transaksi.
  2. Baris 40-42 (for berkas ...): iterasi berjalan pada urutan nama berkas yang terurut; enumerate(..., start=1) mencatat posisi baris di dalam berkasnya supaya laporan karantina bisa menunjuk baris persis.
  3. Baris 43-47 (try/except): baris tidak-valid-baris gagal diparse, langsung masuk karantina dengan alasan “bukan JSON valid”.
  4. Baris 48-54 (tiga aturan): kolom hilang (baris 9005 tanpa total), jumlah minimal 1 (baris 9012 bernilai 0), dan total harus sama dengan jumlah × harga (baris 9004 bernilai 34000 padahal seharusnya 38000).
  5. Baris 59-61 (per_tanggal): baris yang lolos dikelompokkan berdasarkan sepuluh karakter pertama waktu — tanggalnya.
  6. Baris 68-72 (tulis Parquet): setiap kelompok tanggal menjadi satu folder tanggal=YYYY-MM-DD berisi satu berkas Parquet; nilai tanggal hidup di nama folder, bukan di dalam berkas.

Perhatikan juga bahwa keluaran karantina memuat empat baris dengan tiga alasan berbeda: galat parsing, schema tidak lengkap, dan aturan bisnis. Pada praktik nyata, karantina disimpan sebagai dataset tersendiri beserta alasannya supaya bisa dianalisis: pola alasan yang berulang menandakan masalah di sisi pengirim, bukan di sisi lake.

Struktur tanggal=2026-01-15 yang kita pakai di zona curated bukan sekadar gaya penamaan — pola key=value ini memungkinkan engine query memangkas pencarian tanpa membuka berkas. Mekanismenya, hive-style partitioning, dibahas tuntas pada chapter Partitioning Data.