Kita sudah menjelajahi delapan format, dari CSV sampai Protobuf, dan satu benang merah muncul terus-menerus: schema. CSV dan JSON tidak membawa schema sehingga pembaca menebak tipe dari isinya; Avro, Parquet, ORC, dan Protobuf menyimpan tipe di dalam data sehingga pembaca tahu pasti bahwa harga adalah angka, bukan teks. Perbedaan sederhana ini menentukan apakah pipeline gagal keras saat data bermasalah — atau diam-diam menghasilkan angka yang salah. Chapter ini membahas schema sebagai kontrak data, perbedaan tipe logis dan fisik, aturan nullable, dan jebakan konversi tipe yang paling sering menghinggapi data mentah.
Schema sebagai Kontrak Data #
Schema adalah pernyataan formal tentang struktur data: kolom apa saja yang ada, tipe apa yang dimuat setiap kolom, dan kolom mana yang boleh kosong. Dalam praktik industri, schema berfungsi sebagai data contract antara produsen dan konsumen data — aplikasi kasir menjanjikan mengirim qty berupa integer, dan tim analytics berhak menuntut janji itu dipenuhi. Bila produsen mengubah harga menjadi teks tanpa pemberitahuan, kontrak dilanggar dan pipeline wajib gagal di gerbang validasi, bukan menular sampai laporan keuangan.
Format tanpa schema tetap membutuhkan kontrak — hanya saja kontraknya tersirat di dokumentasi atau di kepala anggota tim. Itulah mengapa data mentah CSV hampir selalu divalidasi dan dikonversi ke format ber-schema pada lapisan bronze pipeline.
Tipe Logis vs Tipe Fisik #
Format biner modern memisahkan dua lapisan tipe. Tipe fisik menjawab “bagaimana byte disimpan di berkas”; tipe logis menjawab “apa makna nilai tersebut”. Kita buktikan lewat file Parquet berisi kolom tanggal dan harga desimal:
import datetime as dt
from decimal import Decimal
import pyarrow as pa
import pyarrow.parquet as pq
tabel = pa.table({
"tanggal": pa.array([dt.date(2026, 1, 2), dt.date(2026, 1, 3)], pa.date32()),
"harga": pa.array([Decimal("68000.00"), Decimal("17500.00")], pa.decimal128(10, 2)),
})
pq.write_table(tabel, "tipe_logis.parquet", compression="snappy")
berkas = pq.ParquetFile("tipe_logis.parquet")
for i, kolom in enumerate(berkas.schema_arrow):
fisik = berkas.metadata.row_group(0).column(i).physical_type
print(f"{kolom.name:8} nullable={kolom.nullable!s:5} "
f"arrow={str(kolom.type):16} fisik={fisik}")Output yang Dihasilkan:
tanggal nullable=True arrow=date32[day] fisik=INT32
harga nullable=True arrow=decimal128(10, 2) fisik=FIXED_LEN_BYTE_ARRAYKolom tanggal tersimpan fisik sebagai INT32 — jumlah hari sejak 1 Januari 1970 — tetapi anotasi logis Date memberi tahu pembaca bahwa angka itu adalah tanggal, bukan integer biasa. Kolom harga tersimpan sebagai FIXED_LEN_BYTE_ARRAY dengan anotasi Decimal(precision=10, scale=2) sehingga nilai uang tidak kehilangan presisi seperti halnya float. Pemisahan ini membuat format tetap ringkas di penyimpanan sekaligus kaya makna bagi mesin query — dan nullable=True pada output membuktikan status kekosongan kolom juga tercatat di schema.
Nullable: Saat Kolom Boleh Kosong #
Nullability adalah bagian schema yang paling sering diabaikan padahal paling banyak memicu galat. Parquet menandai setiap kolom optional (boleh kosong) atau required (wajib terisi). Avro menyatakan hal yang sama lewat union seperti ["null", "int"]. SQL membiarkan NULL di hampir semua kolom kecuali dilarang NOT NULL.
Dua jebakan utamanya: pertama, operasi agregasi — SUM(harga) mengabaikan NULL sedangkan COUNT(*) tetap menghitungnya, sehingga rata-rata bisa salah tanpa pesan galat; kedua, penggabungan data — NULL tidak sama dengan NULL pada JOIN, membuat baris hilang diam-diam. Keputusan sebuah kolom nullable atau tidak karenanya bukan detail teknis, melainkan keputusan bisnis yang harus tertulis di schema.
Pemetaan Tipe Antar Format #
Konsep tipe yang sama hidup dengan nama berbeda di setiap dunia. Tabel berikut memetakan konsep yang paling sering kita jumpai:
| Konsep | PostgreSQL | Avro | Parquet | Protobuf |
|---|---|---|---|---|
| Bilangan bulat | INTEGER, BIGINT | int, long | INT32, INT64 | int32, int64 |
| Desimal presisi tetap | NUMERIC(10,2) | bytes + decimal | FIXED_LEN_BYTE_ARRAY + DECIMAL | tidak ada, pakai int64 satuan rupiah |
| Teks | TEXT | string | BYTE_ARRAY + STRING | string |
| Tanggal | DATE | int + date | INT32 + DATE | tidak ada, konvensi tim |
| Timestamp | TIMESTAMPTZ | long + timestamp-millis | INT64 + TIMESTAMP | google.protobuf.Timestamp |
| Boolean | BOOLEAN | boolean | BOOLEAN | bool |
| Boleh kosong | NULL / NOT NULL | union ["null","tipe"] | optional / required | kehadiran tidak dilacak pada skalar proto3 |
Pola dari tabel ini: semakin tua formatnya, semakin sederhana tipenya (CSV hanya teks); semakin ketat kontraknya, semakin kaya tipenya (SQL dan Avro). Protobuf sengaja minim tipe bawaan demi ukuran pesan — urusan tanggal dan desimal diserahkan pada konvensi tim atau tipe well-known.
Konversi Umum: String ke Angka #
Data dari file dan API sering datang sebagai teks. Fungsi int() Python bisa mengubah string ke angka, tetapi tiga kasus berikut menunjukkan mengapa konversi harus eksplisit dan dijaga:
def ke_int(nilai):
try:
return int(nilai)
except (TypeError, ValueError) as e:
print(f" gagal konversi {nilai!r}: {type(e).__name__}: {e}")
return None
print(ke_int("68000"))
print(ke_int("68.000"))
print(ke_int(None))Output yang Dihasilkan:
68000
gagal konversi '68.000': ValueError: invalid literal for int() with base 10: '68.000'
None
gagal konversi None: TypeError: int() argument must be a string, a bytes-like object or a real number, not 'NoneType'
NoneKasus kedua layak dicermati: "68.000" adalah format ribuan Indonesia yang sah ditulis manusia, tetapi int() menolaknya karena menganggap titik sebagai desimal. Aplikasi nyata memanggil pembersihan seperti nilai.replace(".", "") untuk angka ribuan atau Decimal untuk angka desimal sebelum konversi — dan wajib memutuskan kebijakan untuk None, bukan membiarkannya menular.
Validasi Tipe Sebagai Gerbang Pipeline #
Kontrak hanya kuat bila diperiksa. Fungsi kecil berikut membandingkan setiap kolom sebuah baris terhadap schema sebelum baris diteruskan:
SKEMA = {
"id": int,
"kasir": str,
"produk": str,
"qty": int,
"harga": int,
}
def validasi_transaksi(baris):
"""Periksa tipe setiap kolom terhadap SKEMA. Kembalikan daftar galat."""
galat = []
for nama, tipe in SKEMA.items():
if nama not in baris:
galat.append(f"kolom '{nama}' tidak ada")
elif baris[nama] is None:
galat.append(f"kolom '{nama}' bernilai None padahal tidak nullable")
elif type(baris[nama]) is not tipe:
galat.append(
f"kolom '{nama}': harapan {tipe.__name__}, "
f"ditemukan {type(baris[nama]).__name__}"
)
return galat
baris_baik = {"id": 7, "kasir": "Budi", "produk": "Telur 1kg", "qty": 2, "harga": 28000}
baris_buruk = {"id": "8", "kasir": "Siti", "produk": "Tepung 1kg", "qty": None, "harga": "12000"}
print("baris_baik :", validasi_transaksi(baris_baik))
for g in validasi_transaksi(baris_buruk):
print("baris_buruk:", g)Output yang Dihasilkan:
baris_baik : []
baris_buruk: kolom 'id': harapan int, ditemukan str
baris_buruk: kolom 'qty' bernilai None padahal tidak nullable
baris_buruk: kolom 'harga': harapan int, ditemukan strBaris bermasalah tertangkap lengkap: id dan harga berupa string, qty kosong padahal schema melarangnya. Di pipeline produksi, pemeriksaan seperti ini adalah kandidat alat yang lebih matang — Pandera untuk DataFrame atau Great Expectations untuk berkas — dengan prinsip kerja yang sama: bandingkan data dengan schema, tolak yang melanggar.
Schema yang terjaga membuat perubahan bisa dilakukan dengan aman. Bagaimana cara menambah kolom baru tanpa memutus pipeline lama? Kita bahas pada chapter berikutnya: Schema Evolution.