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 MB | apa pun boleh — readlines() pun aman |
| 50 MB – 1 GB | streaming + generator |
| 1 GB – 10 GB | chunk + pindahkan agregasi ke SQLite/DuckDB |
| > 10 GB atau lintas mesin | DuckDB, 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():
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:
readlines(): 100,001 baris, 8,221,034 byte (7.8 MiB)
generator : 192 byte
Total via streaming: 100,001 barisreadlines() 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:
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:
Omzet minuman: Rp12.558.544.500Mari kita bedah alirannya:
- Baris 4-6 (
def baca_csv): generator ini membuka file danyield from csv.DictReader(...)menghasilkan satu record per langkah; file tertutup otomatis ketika generator selesai. - Baris 8-9 (
hanya_minuman): tahap filter — juga generator, sehingga tidak ada list antara yang terbentuk. - Baris 11-12 (
hitung_omzet): tahap konsumen;sum()menarik record darihanya_minuman, yang menarik daribaca_csv, yang menarik dari file. - 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):
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:
Batch 1: 20,000 baris
Batch 2: 20,000 baris
Batch 3: 20,000 baris
Batch 4: 20,000 baris
Batch 5: 20,000 barisSetiap 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):
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:
49,907 baris
Omzet minuman : Rp12.558.544.500
P010: Rp7.557.000.000
P001: Rp2.556.205.000
P004: Rp2.340.546.000executemany 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:
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:
Total qty: 300,230Dengan strategi untuk skala besar di tangan, satu hal tersisa: merangkai seluruh peralatan section ini menjadi pipeline yang utuh, bisa diulang, dan bisa diuji.