Pipeline ETL TokoKita membaca seluruh berkas Parquet lima juta baris ke memori, baru memfilter kota == 'Bandung' di pandas — satu juta baris dipakai, empat juta dibuang. Kode seperti ini berjalan benar, tetapi membayar penuh biaya baca, dekompresi, dan konversi untuk 80% baris yang akhirnya dibuang. Chapter Query Optimization telah membuktikan bahwa engine mendorong filter ke dalam TABLE_SCAN; bab ini memandangnya dari sisi praktik pipeline: di titik mana filter boleh ditempatkan, berapa penghematan nyata di tiap titik, dan di mana batas teknik ini. Empat strategi diukur berdampingan pada berkas yang sama, dengan hasil akhir yang harus identik.

Tiga Titik Penempatan Filter #

Titik penempatanContohData yang dibaca
Setelah loaddf[df["kota"] == "Bandung"]semua baris dan kolom
Di readerds.field("kota") == "Bandung"hanya row group yang statistiknya cocok
Di engineWHERE kota = 'Bandung'row group cocok + hanya kolom yang dirujuk

Pola umumnya jelas: makin dekat filter ke penyimpanan, makin sedikit data yang berpindah. Aturan praktisnya satu kalimat: tuliskan filter sedekat mungkin dengan sumber data — di SQL yang dijalankan engine atau di parameter reader — dan biarkan memori proses menerima hasil yang sudah tersaring. Bila logika filter memang harus ditulis di Python, menempatkannya di reader masih jauh lebih murah daripada setelah konversi ke pandas. Sisa bab ini mengukur seberapa besar bedanya dan mengenal kasus ketika pendekatan ini tidak berlaku.

Peta Row Group: Berapa Byte yang Sebenarnya Dibutuhkan #

Berkas uji dibangun dengan satu keputusan desain penting: lima juta pesanan diurutkan per kota sebelum ditulis, sehingga statistik min-maks setiap row group rapat di satu kota. Inilah syarat pemangkasan bekerja, sebagaimana dibahas di chapter Columnar Storage:

python
if not os.path.exists(BERKAS):
    con.execute(f"""
    COPY (
        SELECT i AS id,
               CASE (i * 5 // 5000000)
                   WHEN 0 THEN 'Bandung' WHEN 1 THEN 'Denpasar' WHEN 2 THEN 'Jakarta'
                   WHEN 3 THEN 'Medan' ELSE 'Surabaya' END AS kota,
               15000.0 + (i % 500) * 250.0 AS nilai,
               'catatan pengiriman untuk pesanan nomor ' || i::VARCHAR AS catatan
        FROM range(5000000) t(i)
        ORDER BY 2
    ) TO '{BERKAS}' (FORMAT parquet, ROW_GROUP_SIZE 500000)
    """)

Peta berkasnya dibaca murni dari metadata — jumlah baris, statistik min-maks, dan ukuran terkompresi per row group, tanpa memindai isinya:

python
cocok, byte_cocok = [], 0
for rg in range(md.num_row_groups):
    grp = md.row_group(rg)
    st = grp.column(1).statistics
    byte_rg = sum(grp.column(c).total_compressed_size for c in range(grp.num_columns))
    tanda = ""
    if st.min <= "Bandung" <= st.max:
        cocok.append(rg)
        byte_cocok += byte_rg
        tanda = "  <- memuat Bandung"
    print(f"  rg{rg}: {st.min}..{st.max}  {byte_rg / 1e6:.1f} MB{tanda}")
print(f"row group wajib dibaca untuk kota='Bandung': {cocok}")
print(f"byte wajib dibaca: {byte_cocok / 1e6:.1f} MB dari {ukuran_file / 1e6:.1f} MB "
      f"({byte_cocok / ukuran_file * 100:.0f}%)")

Output yang Dihasilkan:

text
berkas: 52.5 MB, 10 row group
statistik kolom kota + ukuran terkompresi tiap row group:
  rg0: Bandung..Bandung  5.3 MB  <- memuat Bandung
  rg1: Bandung..Denpasar  5.3 MB  <- memuat Bandung
  rg2: Denpasar..Denpasar  5.3 MB
  rg3: Denpasar..Jakarta  5.3 MB
  rg4: Jakarta..Jakarta  5.3 MB
  rg5: Jakarta..Medan  5.3 MB
  rg6: Medan..Medan  5.3 MB
  rg7: Medan..Surabaya  5.3 MB
  rg8: Surabaya..Surabaya  5.3 MB
  rg9: Surabaya..Surabaya  5.1 MB
row group wajib dibaca untuk kota='Bandung': [0, 1]
byte wajib dibaca: 10.5 MB dari 52.5 MB (20%)

Mari kita telaah bagian-bagian penting kode di atas:

  1. Baris 24-26 (CASE (i * 5 // 5000000)): ekspresi pembagi membagi lima juta baris menjadi lima blok kota masing-masing satu juta baris.
  2. Baris 30 (ORDER BY 2): pengurutan per kolom kota membuat setiap row group memuat satu kota — tanpa ini, statistik min-maks setiap grup membentang ke lima kota dan tidak ada yang bisa dilewati.
  3. Baris 31 (ROW_GROUP_SIZE 500000): ukuran grup pas satu juta baris menghasilkan 10 row group dengan batas kota yang bersih.
  4. Baris 41 dan 44 (pemeriksaan statistik): statistik min-maks kolom kota dibaca dari metadata (baris 41), lalu satu perbandingan st.min <= "Bandung" <= st.max (baris 44) memutuskan row group mana yang dibuka — keputusan baca/tidak dibuat dari metadata, bukan dari data.

Empat Strategi, Satu Hasil yang Sama #

Tiga strategi pertama mengambil baris Bandung dengan posisi filter berbeda; strategi keempat sengaja memakai predikat yang tidak bisa memanfaatkan statistik sebagai pembanding. Setiap strategi dijalankan sekali untuk pemanasan, lalu diukur sebagai median beberapa run — pola yang sama dengan dua chapter sebelumnya:

python
def strategi_a():
    """Baca SEMUA baris & kolom ke pandas, baru filter di pandas."""
    df = pq.read_table(BERKAS).to_pandas()
    return df[df["kota"] == "Bandung"]


def strategi_b():
    """Filter di level reader pyarrow: row group pruning via statistik."""
    dset = ds.dataset(BERKAS, format="parquet")
    return dset.to_table(filter=ds.field("kota") == "Bandung").to_pandas()


Q_DUCK = (f"SELECT count(*), round(sum(nilai), 0) FROM read_parquet('{BERKAS}') "
          "WHERE kota = 'Bandung'")
Q_LIKE = (f"SELECT count(*) FROM read_parquet('{BERKAS}') "
          "WHERE catatan LIKE '%nomor 42%'")

Output yang Dihasilkan:

text
=== Filter kota='Bandung' (1 juta dari 5 juta baris) ===
A. baca semua -> filter pandas   : 3.961 s (1,000,000 baris hasil)
B. filter di reader pyarrow      : 0.598 s (1,000,000 baris hasil)
C. WHERE kota='Bandung' (DuckDB) : 0.0110 s (1,000,000 baris hasil)
D. WHERE catatan LIKE (DuckDB)   : 0.1893 s (111,111 baris hasil)
hasil identik A==B: True

=== Kutipan TABLE_SCAN dari EXPLAIN ANALYZE (query C) ===
  TABLE_SCAN
  ────────────────────
  Function:
  READ_PARQUET
  Projections: nilai
  Filters:
  kota='Bandung'
  Total Files Read: 1

Strategi B membuktikan bahwa setengah jalan pun sudah berarti: reader pyarrow membaca statistik row group, membuka hanya rg0 dan rg1, lalu menyaring sisanya sebelum konversi ke pandas — semua kolom dari dua grup tersebut tetap dibaca, tetapi 42 MB data kota lain tidak pernah disentuh. Ketiga strategi pertama mengembalikan satu juta baris yang sama, tetapi biayanya berjenjang: 3,961 detik bila memfilter di pandas, 0,598 detik di reader, dan 0,011 detik di DuckDB — kutipan rencananya menjelaskan lompatan terakhir: Filters: kota='Bandung' dieksekusi di dalam scan, dan Projections: nilai membuktikan hanya kolom yang dirujuk yang dibaca. Strategi D adalah batasnya: pola LIKE '%nomor 42%' tidak bisa dibandingkan terhadap statistik min-maks, sehingga kolom catatan — kolom termahal di berkas — dibaca penuh dari semua row group.

Angka detik di atas khas mesin uji dan bervariasi antar run — laporan lengkap fungsi read_parquet tersedia di dokumentasi DuckDB . Yang stabil adalah urutan besarannya: memindahkan filter dari pandas ke engine mengubah durasi dari detik menjadi puluhan milidetik pada berkas 52,5 MB, dan rasionya melebar pada berkas yang lebih besar.

Pushdown memangkas baris sebelum dibaca. Namun strategi C di atas hanya benar-benar membutuhkan satu kolom angka dari empat kolom yang tersimpan — pemangkasan pada sumbu lain, yaitu kolom, dibedah di chapter Column Pruning.