Pada chapter sebelumnya, folder tanggal=2026-01-15 muncul sebagai gaya penamaan tanpa penjelasan mendalam. Bab ini membongkar mekanismenya: partitioning menyandikan nilai kolom ke dalam path, sehingga query yang memfilter kolom tersebut bisa melewati seluruh folder lain tanpa membukanya. Efeknya bukan sekadar tampilan — pada lake berisi ribuan berkas, pemangkasan semacam ini menentukan apakah query selesai dalam detik atau menit.
Hive-Style Partitioning #
Konvensi yang paling umum adalah hive-style: setiap tingkat folder bernama kolom=nilai. Data transaksi 8 bulan milik toko online kita tersusun seperti ini:
transaksi/
├── tahun=2025/
│ ├── bulan=7/part-0.parquet
│ ├── bulan=8/part-0.parquet
│ └── ... (bulan 9-12)
└── tahun=2026/
├── bulan=1/part-0.parquet
└── bulan=2/part-0.parquetDua hal penting dari struktur ini. Pertama, kolom tahun dan bulan tidak disimpan di dalam berkas Parquet — nilainya hidup di path. Ukuran berkas jadi lebih kecil, dan satu kolom berubah arti tanpa menulis ulang data. Kedua, nilai di path adalah string: mesin query membaca tahun=2026, mengubahnya menjadi angka, lalu mengekspos kolom tahun seolah berasal dari dalam berkas. Perilaku inilah yang kita lihat di chapter sebelumnya ketika PyArrow menambahkan kolom tanggal secara otomatis.
Tingkat hierarkinya dipilih mengikuti cara data dipakai. Kombinasi tahun/bulan cocok untuk laporan bulanan; data yang hampir selalu ditarik per hari lebih cocok memakai satu tingkat tanggal=YYYY-MM-DD. Nilai ditulis apa adanya dari kolom asalnya — integer 1 menjadi folder bulan=1 — sehingga tidak perlu aturan padding nol, dan pembacaan tetap konsisten antara yang menulis dan yang membaca.
Memilih Kolom Partisi #
Partisi yang tepat menjawab satu pertanyaan: kolom apa yang paling sering muncul di klausa WHERE? Aturan praktisnya: pilih kolom dengan kardinalitas rendah (sedikit nilai unik) yang sekaligus sering difilter — tanggal, bulan, negara, jenis produk. Jangan mempartisi kolom seperti email atau id yang memiliki jutaan nilai unik, karena jumlah foldernya akan membengkak sementara hampir tidak ada query yang bisa memanfaatkannya.
Untuk data toko online kita, filter yang paling sering muncul adalah rentang waktu, sehingga tahun/bulan menjadi kandidat alami. Tabel event dari banyak negara sering kali ditambah satu tingkat negara karena tim tiap wilayah hanya membaca datanya sendiri. Sebaliknya, kolom kategori pada contoh kita tidak layak menjadi partisi: nilainya memang sedikit, tetapi hampir tidak ada laporan yang menyaring berdasarkan kategori saja.
Dosisnya juga harus diukur. Terlalu sedikit partisi membuat pruning jarang menyelamatkan apa pun; terlalu banyak justru menimbulkan masalah baru:
| Aspek | Terlalu sedikit partisi | Terlalu banyak partisi |
|---|---|---|
| Contoh | semua data dalam satu folder | partisi per jam per kota |
| Efek baca | filter tidak memangkas apa pun | listing jutaan folder jadi lambat |
| Efek tulis | berkas raksasa sulit dibaca paralel | berkas mungil menumpuk (small files) |
| Patokan | mulai dari tahun/bulan, ukur, lalu tambah tingkat bila filter masih sering melewatkan banyak berkas | hentikan saat folder per level tidak lagi menyala dalam EXPLAIN |
Praktik: Membuktikan Partition Pruning #
Teori di atas bisa dibuktikan dengan DuckDB; parameter hive_partitioning yang kita pakai terdokumentasi di dokumentasi Parquet DuckDB
. Skrip berikut menulis 2.000 transaksi ke dalam 8 partisi tahun/bulan, lalu membandingkan query berfilter partisi dengan query berfilter kolom biasa. Rencana eksekusi dicetak lewat EXPLAIN agar jumlah berkas yang dibuka terlihat:
import calendar
import pathlib
import random
import shutil
import pyarrow as pa
import pyarrow.dataset as ds
import duckdb
BASE = pathlib.Path(__file__).resolve().parent / "partisi"
shutil.rmtree(BASE, ignore_errors=True)
LAKE = BASE / "transaksi"
# --- Tahap 1: data transaksi toko online selama 8 bulan ---
acak = random.Random(7)
PELANGGAN = ["Budi", "Siti", "Dewi", "Rina"]
KATALOG = {
"Kopi Gayo 250g": 45000, "Kopi Toraja 200g": 38000, "Kopi Robusta 1kg": 95000,
"Teh Melati 100g": 16000, "Teh Hijau 100g": 18000, "Cangkir Keramik": 55000,
"Filter Kertas 100pcs": 12000, "Susu Kental 350g": 15000,
}
bulan_aktif = [(2025, b) for b in range(7, 13)] + [(2026, b) for b in (1, 2)]
baris = []
id_berikut = 20001
for tahun, bulan in bulan_aktif:
hari_maks = calendar.monthrange(tahun, bulan)[1]
for i in range(250):
produk = acak.choice(list(KATALOG))
jumlah = acak.randint(1, 3)
baris.append({
"id": id_berikut,
"tahun": tahun,
"bulan": bulan,
"tanggal": f"{tahun}-{bulan:02d}-{(i % hari_maks) + 1:02d}",
"pelanggan": acak.choice(PELANGGAN),
"produk": produk,
"kategori": produk.split()[0],
"jumlah": jumlah,
"harga": KATALOG[produk],
"total": jumlah * KATALOG[produk],
})
id_berikut += 1
tabel = pa.Table.from_pylist(baris)
partisi = ds.partitioning(
pa.schema([("tahun", pa.int64()), ("bulan", pa.int64())]), flavor="hive")
ds.write_dataset(tabel, LAKE, format="parquet", partitioning=partisi)
# --- Tahap 2: verifikasi struktur partisi di disk ---
folder = sorted(p.relative_to(LAKE).as_posix()
for p in LAKE.rglob("*.parquet"))
print("partisi di disk:", folder)
print("total file parquet:", len(folder))
# --- Tahap 3: query dengan filter partisi (partition pruning) ---
glob = f"{LAKE.as_posix()}/**/*.parquet"
satu_folder = f"{LAKE.as_posix()}/tahun=2026/bulan=2/*.parquet"
plan = duckdb.sql(
f"EXPLAIN SELECT count(*) FROM read_parquet('{glob}', hive_partitioning=true) "
"WHERE tahun = 2026 AND bulan = 2").fetchone()[1]
print(plan)
hasil_pruning = duckdb.sql(
f"SELECT count(*) AS baris FROM read_parquet('{glob}', hive_partitioning=true) "
"WHERE tahun = 2026 AND bulan = 2").fetchone()[0]
hasil_langsung = duckdb.sql(
f"SELECT count(*) AS baris FROM read_parquet('{satu_folder}')"
).fetchone()[0]
print("baris via pruning:", hasil_pruning, "| via satu folder:", hasil_langsung)
# --- Tahap 4: filter pada kolom biasa tidak memangkas file ---
plan2 = duckdb.sql(
f"EXPLAIN SELECT count(*) FROM read_parquet('{glob}', hive_partitioning=true) "
"WHERE kategori = 'Kopi'").fetchone()[1]
print(plan2)Output yang Dihasilkan:
partisi di disk: ['tahun=2025/bulan=10/part-0.parquet', 'tahun=2025/bulan=11/part-0.parquet', 'tahun=2025/bulan=12/part-0.parquet', 'tahun=2025/bulan=7/part-0.parquet', 'tahun=2025/bulan=8/part-0.parquet', 'tahun=2025/bulan=9/part-0.parquet', 'tahun=2026/bulan=1/part-0.parquet', 'tahun=2026/bulan=2/part-0.parquet']
total file parquet: 8
┌───────────────────────────┐
│ UNGROUPED_AGGREGATE │
│ ──────────────────── │
│ Aggregates: │
│ count_star() │
└─────────────┬─────────────┘
┌─────────────┴─────────────┐
│ READ_PARQUET │
│ ──────────────────── │
│ Function: │
│ READ_PARQUET │
│ │
│ File Filters: │
│ (tahun = 2026)(bulan = 2) │
│ │
│ Scanning Files: 1/8 │
│ │
│ ~250 rows │
└───────────────────────────┘
baris via pruning: 250 | via satu folder: 250
┌───────────────────────────┐
│ UNGROUPED_AGGREGATE │
│ ──────────────────── │
│ Aggregates: │
│ count_star() │
└─────────────┬─────────────┘
┌─────────────┴─────────────┐
│ READ_PARQUET │
│ ──────────────────── │
│ Function: │
│ READ_PARQUET │
│ │
│ Filters: │
│ kategori='Kopi' │
│ │
│ ~1,600 rows │
└───────────────────────────┘Mari kita telaah baris-baris kode di atas:
- Baris 15 (
random.Random(7)): generator angka dengan benih tetap membuat data contoh deterministik — setiap eksekusi menghasilkan baris yang sama persis. - Baris 46-48 (
partitioning): schema partisi mendeklarasikantahundanbulansebagai integer dengan flavorhive;write_datasetlalu memindahkan kedua kolom ini dari berkas ke nama folder. - Baris 57-58 (
glob): path absolut dipakai agar skrip berjalan benar dari direktori mana pun;**mencocokkan semua tingkat partisi. - Baris 59-61 (
EXPLAIN): rencana pertama memperlihatkan dua baris kunci —File Filters: (tahun = 2026)(bulan = 2)berarti filter diterapkan sebelum berkas dibuka, danScanning Files: 1/8berarti hanya satu berkas dari delapan yang dibaca. - Baris 64-70 (dua query setara): query dengan filter partisi menghasilkan 250 baris, identik dengan query yang membaca satu folder
tahun=2026/bulan=2secara langsung — bukti bahwa pruning tidak mengubah hasil, hanya jumlah berkas yang disentuh. - Baris 73-75 (
plan2): filterkategori = 'Kopi'tidak bisa dipakai untuk memangkas path. Rencana kedua hanya menampilkanFiltersbiasa tanpa barisScanning Files— kedelapan berkas tetap dibuka dan filter diterapkan baris demi baris di dalamnya.
Partisi mengatur data ke samping (folder per nilai). Masalah berikutnya bersifat ke dalam: seberapa besar sebuah berkas Parquet seharusnya, dan apa yang terjadi jika job streaming menulis berkas kecil sepanjang hari. Keduanya dibahas pada chapter File Layout dan Small Files.