Coba bayangkan seorang analis menghadapi dilema: file berisi lima juta baris transaksi, dan proses Python untuk memuat seluruhnya langsung menutup setiap aplikasi lain di laptop. Ini pengalaman yang umum terjadi. “Data besar” dalam praktik sehari-hari bukan berarti petabyte di cluster — data disebut besar begitu ukurannya melebihi memori yang tersedia. Untungnya, sebagian besar masalah ini selesai bukan dengan membeli RAM baru, tetapi dengan mengubah cara kita membaca: satu baris pada satu waktu. Konsep lazy evaluation-nya sudah dibahas di chapter yield dan Generator Functions dan Lazy Evaluation; di sini kita terapkannya untuk data.

“Besar” Itu Relatif terhadap Memori #

Ukuran file di disk dan jejaknya di memori berbeda jauh. File CSV 100 MB yang dimuat sebagai list of dictionary Python bisa memakan 500 MB lebih — setiap string, list, dan dictionary membawa overhead objeknya sendiri (materi chapter Memahami Memory Management). Sebagai patokan praktis:

Ukuran data (perkiraan)Pendekatan yang cukup
< 50 MBapa pun boleh — readlines() pun aman
50 MB – 1 GBstreaming + generator
1 GB – 10 GBchunk + pindahkan agregasi ke SQLite/DuckDB
> 10 GB atau lintas mesinDuckDB, dask, atau sistem terdistribusi

Streaming: Satu Baris pada Satu Waktu #

Perbedaan readlines() dan generator bukan gaya kode — keduanya menghasilkan jumlah baris yang sama, tetapi jejak memorinya berbeda ribuan kali lipat. Untuk pengujian, kita pakai penjualan_besar.csv berisi 100.001 baris (100.000 transaksi plus header) yang bisa dibuat dengan random dan loop penulisan sederhana. Ukur keduanya dengan sys.getsizeof():

python
import sys

with open("penjualan_besar.csv", encoding="utf-8") as berkas:
    semua_baris = berkas.readlines()

memori_list = sys.getsizeof(semua_baris) + sum(sys.getsizeof(b) for b in semua_baris)
print(f"readlines(): {len(semua_baris):,} baris, {memori_list:,} byte "
      f"({memori_list / 1_048_576:.1f} MiB)")
del semua_baris

with open("penjualan_besar.csv", encoding="utf-8") as berkas:
    aliran = (baris for baris in berkas)
    print(f"generator  : {sys.getsizeof(aliran)} byte")
    print(f"Total via streaming: {sum(1 for _ in aliran):,} baris")

Output yang Dihasilkan:

text
readlines(): 100,001 baris, 8,221,034 byte (7.8 MiB)
generator  : 192 byte
Total via streaming: 100,001 baris

readlines() menyimpan seluruh file sebagai list — 7,8 MiB untuk file 3,3 MB ini, dan tumbuh linear: file 100 kali lebih besar butuh memori 100 kali lebih besar. Object generator hanya 192 byte berapa pun panjang filenya, karena fungsi open() membaca dari disk satu baris setiap kali generator diminta baris berikutnya. Kedua pendekatan menghitung 100.001 baris dengan hasil identik.

Generator sebagai Pipeline #

Kekuatan sesungguhnya muncul saat generator dirangkai menjadi pipeline: setiap tahap adalah generator yang menerima iterator dan menghasilkan iterator. Kita hitung omzet kategori minuman dari file besar tanpa pernah menyimpan seluruh data:

python
import csv

def baca_csv(nama_berkas):
    with open(nama_berkas, newline="", encoding="utf-8") as berkas:
        yield from csv.DictReader(berkas)

def hanya_minuman(rekam):
    return (r for r in rekam if r["kategori"] == "minuman")

def hitung_omzet(rekam):
    return sum(int(r["harga"]) * int(r["qty"]) for r in rekam)

omzet = hitung_omzet(hanya_minuman(baca_csv("penjualan_besar.csv")))
print(f"Omzet minuman: Rp{omzet:,}".replace(",", "."))

Output yang Dihasilkan:

text
Omzet minuman: Rp12.558.544.500

Mari kita bedah alirannya:

  1. Baris 4-6 (def baca_csv): generator ini membuka file dan yield from csv.DictReader(...) menghasilkan satu record per langkah; file tertutup otomatis ketika generator selesai.
  2. Baris 8-9 (hanya_minuman): tahap filter — juga generator, sehingga tidak ada list antara yang terbentuk.
  3. Baris 11-12 (hitung_omzet): tahap konsumen; sum() menarik record dari hanya_minuman, yang menarik dari baca_csv, yang menarik dari file.
  4. Baris 14 (omzet = hitung_omzet(hanya_minuman(baca_csv(...)))): ketiga tahap tersusun seperti pipa; pada satu momen hanya ada satu record yang sedang diproses.

Perhatikan urutan penyusunannya: filter hanya_minuman berdiri sebelum hitung_omzet. Menempatkan filter sedini mungkin (push filter down) berarti konversi int() hanya dijalankan pada baris minuman, bukan seluruh 100.000 baris.

Pemrosesan per Potongan #

Kadang proses per baris terlalu halus — misalnya kita butuh menulis laporan per 20.000 baris atau mengukur progres. Fungsi islice() dari chapter itertools memotong iterator menjadi potongan (chunk):

python
from itertools import islice

def potongan(pembaca, ukuran):
    while batch := list(islice(pembaca, ukuran)):
        yield batch

for nomor, batch in enumerate(potongan(baca_csv("penjualan_besar.csv"), 20_000), start=1):
    print(f"Batch {nomor}: {len(batch):,} baris")

Output yang Dihasilkan:

text
Batch 1: 20,000 baris
Batch 2: 20,000 baris
Batch 3: 20,000 baris
Batch 4: 20,000 baris
Batch 5: 20,000 baris

Setiap batch adalah list 20.000 record yang hidup sebentar lalu digantikan batch berikutnya — puncak memori kita kira-kira ukuran satu potongan, bukan seluruh file. Operator walrus := (chapter Walrus Operator) membuat loop berhenti rapi ketika islice tidak lagi menghasilkan baris.

Memindahkan Agregasi ke SQLite #

Ketika data perlu dianalisis berulang kali dengan sudut pandang berbeda, membaca ulang dari CSV setiap kali menjadi boros. SQLite menyimpan data di disk dengan struktur tabel dan indeks, lalu melakukan agregasi dengan SQL — sambil menjaga memori tetap rendah (materi chapter SQLite dengan Python):

python
import sqlite3
from pathlib import Path

Path("penjualan.sqlite").unlink(missing_ok=True)
koneksi = sqlite3.connect("penjualan.sqlite")
koneksi.execute(
    "CREATE TABLE penjualan (tanggal TEXT, sku TEXT, kategori TEXT, qty INT, harga INT)"
)
with koneksi:
    koneksi.executemany(
        "INSERT INTO penjualan VALUES (?, ?, ?, ?, ?)",
        ((r["tanggal"], r["sku"], r["kategori"], int(r["qty"]), int(r["harga"]))
         for r in baca_csv("penjualan_besar.csv")),
    )

total_baris, omzet_minuman = koneksi.execute(
    "SELECT COUNT(*), SUM(qty * harga) FROM penjualan WHERE kategori = 'minuman'"
).fetchone()
print(f"{total_baris:,} baris")
print(f"Omzet minuman  : Rp{omzet_minuman:,}".replace(",", "."))

for sku, total in koneksi.execute(
    "SELECT sku, SUM(qty * harga) AS total FROM penjualan GROUP BY sku ORDER BY total DESC LIMIT 3"
):
    print(f"{sku}: Rp{total:,}".replace(",", "."))
koneksi.close()

Output yang Dihasilkan:

text
49,907 baris
Omzet minuman  : Rp12.558.544.500
P010: Rp7.557.000.000
P001: Rp2.556.205.000
P004: Rp2.340.546.000

executemany di baris 10 menerima generator baca_csv(...) secara langsung — pengisian tabel pun berjalan streaming. Omzet minuman (Rp12.558.544.500) identik dengan hasil pipeline generator, karena keduanya membaca sumber yang sama dengan benar. Setelah data berada di SQLite, pertanyaan baru tinggal satu query, tanpa membaca ulang file. Di luar standard library, DuckDB bahkan bisa menjalankan SQL langsung di atas file CSV, dan dask memperluas pola DataFrame pandas ke data yang lebih besar dari memori.

Format Kompak dan pandas #

CSV mengulang nama kolom, tanda pemisah, dan teks yang sama berjuta-juta kali, lalu memaksa kita mengurai ulang setiap kali membaca. Format kolom binary seperti Parquet menyimpan tipe data dan kompresi di dalam file — lebih kecil dan jauh lebih cepat dibaca, meskipun butuh library seperti pyarrow. pandas tetap berguna di dunia besar lewat parameter chunksize, yang mengubah read_csv() menjadi iterator DataFrame:

python
import pandas as pd

total_qty = 0
for potongan_df in pd.read_csv("penjualan_besar.csv", chunksize=20_000):
    total_qty += int(potongan_df["qty"].sum())
print(f"Total qty: {total_qty:,}")

Output yang Dihasilkan:

text
Total qty: 300,230

Dengan strategi untuk skala besar di tangan, satu hal tersisa: merangkai seluruh peralatan section ini menjadi pipeline yang utuh, bisa diulang, dan bisa diuji.