Bab 14 — Format Data: CSV, JSON, dan Hati-hati dengan Pickle

Seri B3 — Program Kecil yang Berguna

CatatanDi bab ini, Anda akan:
  • Menyimpan dan membaca tabel dengan modul csv
  • Menyimpan dan membaca data bersarang dengan modul json
  • Mengerti serialisasi, dan tahu mengapa nilai dari CSV selalu berupa teks
  • Memahami bahaya pickle dan membuktikannya secara aman
  • Menyimpan data kelima sektor ke CSV dan JSON, lalu memuatnya kembali dengan tahan salah

Semua skrip bab ini ada di folder kode/bab14/. Di versi web, Anda juga bisa mengunduh semuanya sekaligus (zip). Berkas hasil ditulis ke subfolder _tmp/.

Di Bab 13, kita menyimpan data pohon dengan format buatan sendiri (A|Jati|32.5) dan sudah curiga formatnya rapuh. Bayangkan dua petugas yang saling mengirim catatan: bila masing-masing punya cara menulis sendiri, tidak ada yang bisa membaca catatan lainnya. Maka dunia sepakat pada beberapa format standar. Mengubah data di memori menjadi teks (atau byte) yang bisa disimpan disebut serialisasi; mengembalikannya disebut deserialisasi. Bab ini membahas tiga format: CSV, JSON, dan pickle.

CSV menyimpan data sebagai tabel datar; JSON menyimpan data bersarang dengan tanda kurung. Keduanya teks biasa.

CSV: tabel sebagai teks

CSV (comma-separated values) adalah tabel yang tiap barisnya satu catatan dan tiap kolomnya dipisah koma. Berkasnya bisa dibuka Excel. Kita menulis data kolam dari Bab 11:

01_csv_tulis.py
# Bab 14 - Menulis CSV: tabel sebagai teks berkolom koma
import csv
from pathlib import Path

from data_inti import ikan

tmp = Path(__file__).parent / "_tmp"
tmp.mkdir(exist_ok=True)
berkas = tmp / "ikan.csv"

kolom = ["kolam", "jenis", "jumlah", "bobot_rata_g", "ph_air"]
with open(berkas, "w", newline="", encoding="utf-8") as f:     # newline="" wajib untuk csv
    penulis = csv.DictWriter(f, fieldnames=kolom)
    penulis.writeheader()                                      # baris judul kolom
    penulis.writerows(ikan)                                    # semua baris sekaligus

print(berkas.read_text(encoding="utf-8"))
kolam,jenis,jumlah,bobot_rata_g,ph_air
A,lele,800,540.5,7.2
B,nila,1250,300.0,6.9
C,lele,600,480.0,7.0
  • DictWriter menulis daftar dict; fieldnames menentukan urutan kolom.
  • writeheader() menulis baris judul, writerows() menulis semua baris.
  • newline="" pada open() wajib untuk modul csv; dokumentasi Python menyebut berkas CSV harus dibuka dengan newline='' agar akhir baris ditangani dengan benar.

Data ini data tiruan (angka contoh).

Membaca CSV: semuanya teks

02_csv_baca.py
# Bab 14 - Membaca CSV: semua nilai datang sebagai TEKS
import csv
from pathlib import Path

tmp = Path(__file__).parent / "_tmp"
tmp.mkdir(exist_ok=True)
berkas = tmp / "ikan_baca.csv"
berkas.write_text("kolam,jenis,jumlah,bobot_rata_g\nA,lele,800,540.5\nB,nila,1250,300.0\n",
                  encoding="utf-8")

with open(berkas, newline="", encoding="utf-8") as f:
    baris = list(csv.DictReader(f))          # tiap baris menjadi dict berkunci judul kolom

print(baris[0])
print("Tipe jumlah:", type(baris[0]["jumlah"]).__name__)
print("Salah  :", baris[0]["jumlah"] + baris[1]["jumlah"])      # teks digabung, bukan dijumlah

for b in baris:                              # ubah tipe sendiri
    b["jumlah"] = int(b["jumlah"])
    b["bobot_rata_g"] = float(b["bobot_rata_g"])
print("Benar  :", baris[0]["jumlah"] + baris[1]["jumlah"])
{'kolam': 'A', 'jenis': 'lele', 'jumlah': '800', 'bobot_rata_g': '540.5'}
Tipe jumlah: str
Salah  : 8001250
Benar  : 2050

DictReader membuat dict per baris dengan kunci dari baris judul. Perhatikan: 'jumlah': '800' diapit tanda kutip, artinya teks, bukan angka. Dokumentasi csv menegaskan tidak ada konversi tipe otomatis. Akibatnya "800" + "1250" menghasilkan 8001250 (teks digabung), bukan 2050. Anda sendiri yang harus mengubah tipenya dengan int() dan float().

PeringatanAWAS

Nilai True dan False di CSV juga teks. bool("False") menghasilkan True (karena teks tak kosong dianggap benar)! Cocokkan teksnya secara eksplisit, seperti pada proyek di bawah.

Mengapa bukan split(",")

03_csv_koma_dalam_isi.py
# Bab 14 - Koma di dalam isi: alasan memakai modul csv, bukan split(",")
import csv
import io

mentah = 'plot,catatan\nA,"dekat sungai, tanah gambut"\nB,"disebut ""hutan keramat"" oleh warga"\n'
print("Isi mentah:")
print(mentah)

print("Cara naif split(','):")
for baris in mentah.splitlines()[1:]:
    print("  ", baris.split(","))

print("Memakai csv.reader:")
for baris in csv.reader(io.StringIO(mentah)):
    print("  ", baris)
Isi mentah:
plot,catatan
A,"dekat sungai, tanah gambut"
B,"disebut ""hutan keramat"" oleh warga"

Cara naif split(','):
   ['A', '"dekat sungai', ' tanah gambut"']
   ['B', '"disebut ""hutan keramat"" oleh warga"']
Memakai csv.reader:
   ['plot', 'catatan']
   ['A', 'dekat sungai, tanah gambut']
   ['B', 'disebut "hutan keramat" oleh warga']

Catatan “dekat sungai, tanah gambut” memuat koma. CSV membungkus isi seperti itu dengan tanda kutip, dan tanda kutip di dalam isi digandakan (""). split(",") buta terhadap aturan itu dan memotong catatan menjadi tiga potong yang salah; csv.reader menanganinya dengan benar. Inilah pelajaran dari Bab 13: format buatan sendiri akan menabrak kasus yang tidak Anda duga, sedangkan modul standar sudah memikirkannya.

JSON: data bersarang

CSV hanya cocok untuk tabel datar. Untuk data bersarang (misalnya dict berisi list), pakai JSON (JavaScript Object Notation), format teks yang dipakai hampir semua layanan internet.

04_json_dasar.py
# Bab 14 - JSON: data bersarang sebagai teks
import json

kolam = {"kolam": "A", "jenis": "lele", "jumlah": 800, "ph_air": 7.2,
         "aktif": True, "catatan": None, "sampel_g": [520, 480, 560]}

teks = json.dumps(kolam)                     # dict -> teks JSON
print(teks)
print(json.dumps(kolam, indent=2, ensure_ascii=False)[:90], "...")

kembali = json.loads(teks)                   # teks JSON -> dict
print(kembali == kolam, type(kembali).__name__)

print(json.dumps({"suhu": "28 °C"}))                          # bawaan: huruf khusus di-escape
print(json.dumps({"suhu": "28 °C"}, ensure_ascii=False))      # tampil apa adanya
print(json.dumps((1, 2)), json.dumps({1: "a"}))              # tuple jadi list, kunci angka jadi teks
{"kolam": "A", "jenis": "lele", "jumlah": 800, "ph_air": 7.2, "aktif": true, "catatan": null, "sampel_g": [520, 480, 560]}
{
  "kolam": "A",
  "jenis": "lele",
  "jumlah": 800,
  "ph_air": 7.2,
  "aktif": true,
   ...
True dict
{"suhu": "28 \u00b0C"}
{"suhu": "28 °C"}
[1, 2] {"1": "a"}
  • json.dumps() mengubah objek Python menjadi teks JSON (s = string); json.loads() kebalikannya.
  • Padanan tipe: True menjadi true, None menjadi null, dict menjadi objek {...}, list menjadi larik [...]. Teks JSON memakai tanda kutip ganda.
  • indent=2 merapikan tampilan agar enak dibaca manusia.
  • ensure_ascii=False menampilkan ° apa adanya; tanpa itu, JSON menulis ° (tetap sah, hanya kurang enak dibaca).
  • Dua kejutan di baris terakhir: tuple (1, 2) kembali sebagai list, dan kunci angka 1 menjadi teks "1". JSON tidak punya tuple, dan kuncinya selalu teks.

JSON ke berkas

05_json_berkas.py
# Bab 14 - JSON ke dan dari berkas, dengan data bersarang lima sektor
import json
from pathlib import Path

from data_inti import pohon, tanaman, ikan, ternak

tmp = Path(__file__).parent / "_tmp"
tmp.mkdir(exist_ok=True)
berkas = tmp / "inventaris.json"

semua = {"pohon": pohon, "tanaman": tanaman, "ikan": ikan, "ternak": ternak}

with open(berkas, "w", encoding="utf-8") as f:
    json.dump(semua, f, indent=2, ensure_ascii=False)

with open(berkas, encoding="utf-8") as f:
    kembali = json.load(f)

print("Sama persis?", kembali == semua)
print("Bagian:", list(kembali))
print("Pohon pertama:", kembali["pohon"][0])
print("Tipe 'sehat':", type(kembali["pohon"][2]["sehat"]).__name__)
print("Ukuran berkas:", berkas.stat().st_size, "byte")
Sama persis? True
Bagian: ['pohon', 'tanaman', 'ikan', 'ternak']
Pohon pertama: {'jenis': 'Jati', 'diameter_cm': 32.5, 'tinggi_m': 18.0, 'sehat': True, 'plot': 'A'}
Tipe 'sehat': bool
Ukuran berkas: 2200 byte

Berbeda dengan CSV, JSON mengembalikan tipe: sehat kembali sebagai bool, angka kembali sebagai int atau float, dan seluruh struktur (empat tabel dalam satu dict) kembali persis sama dengan aslinya. (Ukuran berkas dihitung pada Windows, dan bisa sedikit berbeda di komputer lain karena akhir baris.) Untuk menyimpan satu set data utuh, JSON biasanya lebih nyaman; untuk tabel besar yang akan dibuka di Excel, CSV lebih cocok.

Dua masalah JSON

06_json_masalah.py
# Bab 14 - Dua masalah JSON yang umum: teks rusak dan tipe yang tidak dikenal
import json
from datetime import date

rusak = '{"kolam": "A", "jumlah": 800,}'      # koma berlebih di akhir: JSON tidak mengizinkan
try:
    json.loads(rusak)
except json.JSONDecodeError as e:
    print("JSON rusak:", type(e).__name__, "pada baris", e.lineno)

catatan = {"kolam": "A", "tanggal_tebar": date(2026, 3, 1)}
try:
    json.dumps(catatan)
except TypeError as e:
    print("Tidak bisa:", e)

print("Dengan default=str:", json.dumps(catatan, default=str))
print("Cara tetap: simpan tanggal sebagai teks ISO ->", date(2026, 3, 1).isoformat())
JSON rusak: JSONDecodeError pada baris 1
Tidak bisa: Object of type date is not JSON serializable
Dengan default=str: {"kolam": "A", "tanggal_tebar": "2026-03-01"}
Cara tetap: simpan tanggal sebagai teks ISO -> 2026-03-01
  1. Teks rusak: koma berlebih di akhir membuat JSONDecodeError. Ini sering terjadi bila JSON ditulis atau disunting tangan. Pesan lengkapnya menyebut baris dan kolom; kami hanya menampilkan nama error dan nomor barisnya karena kalimat pesan berbeda antarversi Python.
  2. Tipe tak dikenal: JSON tidak mengenal tanggal, jadi json.dumps melempar TypeError. Jalan keluar cepat: default=str. Kebiasaan yang lebih baik: simpan tanggal sebagai teks ISO (2026-03-01) dan ubah kembali dengan date.fromisoformat() (Bab 11).

Pickle: praktis, tetapi berbahaya

Pickle menyimpan hampir apa saja dari Python (objek buatan sendiri, fungsi, set) ke dalam byte. Praktis, tetapi dokumentasi Python menyatakan terang-terangan bahwa modul pickle tidak aman dan hanya boleh dipakai untuk data yang Anda percayai, karena data pickle yang dirancang jahat dapat menjalankan kode apa saja saat dibuka. Mari buktikan, dengan cara yang aman: kami membuat pickle yang hanya menjalankan print, tidak ada perintah yang berbahaya.

07_pickle_bahaya.py
# Bab 14 - pickle: praktis untuk data sendiri, BERBAHAYA untuk data orang lain
import pickle
import pickletools


class Pembawa:
    """Objek yang sengaja membuat pickle MENJALANKAN fungsi saat dibuka."""
    def __reduce__(self):
        # Di serangan nyata, ini bisa berupa perintah merusak. Di sini hanya print.
        return (print, ("!!! kode ini berjalan hanya karena pickle.loads() dipanggil",))


aman = {"kolam": "A", "jumlah": 800}
paket_aman = pickle.dumps(aman)
print("Pickle data sendiri dibuka:", pickle.loads(paket_aman))

paket_berisiko = pickle.dumps(Pembawa(), protocol=4)    # protokol dipatok agar keluaran sama di semua versi
print("Sebelum loads: belum ada apa-apa yang berjalan")
pickle.loads(paket_berisiko)                  # <- inilah bahayanya
print("Setelah loads: print di atas dijalankan oleh pickle, bukan oleh kita")

print("--- melihat isi pickle TANPA membukanya (pickletools) ---")
pickletools.dis(paket_berisiko)
Pickle data sendiri dibuka: {'kolam': 'A', 'jumlah': 800}
Sebelum loads: belum ada apa-apa yang berjalan
!!! kode ini berjalan hanya karena pickle.loads() dipanggil
Setelah loads: print di atas dijalankan oleh pickle, bukan oleh kita
--- melihat isi pickle TANPA membukanya (pickletools) ---
    0: \x80 PROTO      4
    2: \x95 FRAME      88
   11: \x8c SHORT_BINUNICODE 'builtins'
   21: \x94 MEMOIZE    (as 0)
   22: \x8c SHORT_BINUNICODE 'print'
   29: \x94 MEMOIZE    (as 1)
   30: \x93 STACK_GLOBAL
   31: \x94 MEMOIZE    (as 2)
   32: \x8c SHORT_BINUNICODE '!!! kode ini berjalan hanya karena pickle.loads() dipanggil'
   93: \x94 MEMOIZE    (as 3)
   94: \x85 TUPLE1
   95: \x94 MEMOIZE    (as 4)
   96: R    REDUCE
   97: \x94 MEMOIZE    (as 5)
   98: .    STOP
highest protocol among opcodes = 4

Telusuri hasilnya:

  1. Pickle data sendiri (dict kolam) terbuka biasa.
  2. Kita membuat pickle dari objek Pembawa yang __reduce__-nya berkata “tolong panggil fungsi ini saat pickle dibuka”.
  3. Di antara dua print kita, muncul baris !!! kode ini berjalan .... Kita tidak memanggil print itu; pickle.loads() yang memanggilnya. Bila isinya perintah penghapus berkas, hasilnya sama: berjalan tanpa izin.
  4. pickletools.dis() membaca pickle tanpa menjalankannya dan memperlihatkan dengan jelas bahwa pickle itu memanggil builtins.print (STACK_GLOBAL lalu REDUCE). Dokumentasi Python juga menyarankan python -m pickletools sebagai cara yang lebih aman daripada membuka pickle untuk memeriksanya.
PeringatanAWAS

Jangan pernah membuka (pickle.load atau pickle.loads) berkas pickle yang berasal dari orang lain, dari internet, atau dari surel, dan jangan menerimanya dari pengguna aplikasi web. Untuk data dari luar, pakai JSON atau CSV. Pickle hanya untuk data yang Anda sendiri tulis dan simpan di tempat yang aman.

Proyek Bersambung B3: simpan dan muat lima sektor

Data lima sektor (hutan, kebun, pertanian, perikanan, peternakan; kebun dan pertanian berbagi tabel tanaman) kini disimpan ke CSV dan JSON. Perkakasnya ada di modul alat_data.py: simpan_csv, muat_csv (dengan skema yang menyebut pengubah tipe tiap kolom), simpan_json, dan muat_json.

08_proyek_simpan.py
# Bab 14 - Proyek B3: data kelima sektor disimpan ke CSV dan JSON, dibaca kembali, dibandingkan
from pathlib import Path

from alat_data import simpan_csv, muat_csv, simpan_json, muat_json
from data_inti import pohon, tanaman, ikan, ternak

tmp = Path(__file__).parent / "_tmp"
tmp.mkdir(exist_ok=True)

tabel = {"pohon": pohon, "tanaman": tanaman, "ikan": ikan, "ternak": ternak}
bool_teks = lambda s: {"True": True, "False": False}[s]       # KeyError bila bukan True/False
skema = {
    "pohon": {"jenis": str, "diameter_cm": float, "tinggi_m": float, "sehat": bool_teks, "plot": str},
    "tanaman": {"komoditas": str, "blok": str, "umur_hari": int, "hasil_kg": float},
    "ikan": {"kolam": str, "jenis": str, "jumlah": int, "bobot_rata_g": float, "ph_air": float},
    "ternak": {"kandang": str, "jenis": str, "umur_bulan": int, "bobot_kg": float},
}

for nama, baris in tabel.items():
    simpan_csv(tmp / f"{nama}.csv", baris)
simpan_json(tmp / "inventaris.json", tabel)

for nama in tabel:
    dibaca, ditolak = muat_csv(tmp / f"{nama}.csv", skema[nama])
    sama = dibaca == tabel[nama]
    print(f"{nama:8} CSV: {len(dibaca)} baris, ditolak {ditolak}, sama dengan asli: {sama}")

print("JSON utuh sama dengan asli:", muat_json(tmp / "inventaris.json") == tabel)
print("Isi pohon.csv (3 baris pertama):")
for baris in (tmp / "pohon.csv").read_text(encoding="utf-8").splitlines()[:3]:
    print(baris)
pohon    CSV: 6 baris, ditolak 0, sama dengan asli: True
tanaman  CSV: 4 baris, ditolak 0, sama dengan asli: True
ikan     CSV: 3 baris, ditolak 0, sama dengan asli: True
ternak   CSV: 4 baris, ditolak 0, sama dengan asli: True
JSON utuh sama dengan asli: True
Isi pohon.csv (3 baris pertama):
jenis,diameter_cm,tinggi_m,sehat,plot
Jati,32.5,18.0,True,A
Meranti,45.2,27.5,True,A

Hasil sama dengan asli: True untuk keempat tabel membuktikan bahwa data yang kembali dari CSV (setelah dikonversi sesuai skema) identik dengan yang disimpan, termasuk kolom sehat yang berupa bool. JSON juga identik. Sekarang uji ketahanannya:

09_proyek_tahan_salah.py
# Bab 14 - Proyek B3: memuat CSV yang sudah dikotori petugas, dan berkas yang hilang atau rusak
import logging
import sys
from pathlib import Path

from alat_data import simpan_csv, muat_csv, muat_json
from data_inti import tanaman

logging.basicConfig(stream=sys.stdout, level=logging.WARNING, format="%(levelname)s: %(message)s")

tmp = Path(__file__).parent / "_tmp"
tmp.mkdir(exist_ok=True)
berkas = tmp / "tanaman_kotor.csv"

simpan_csv(berkas, tanaman)
with open(berkas, "a", newline="", encoding="utf-8") as f:       # ulah petugas lapangan
    f.write("padi,E,seratus,250.0\n")                            # umur bukan angka
    f.write("jagung,F,95\n")                                     # kolom kurang

skema = {"komoditas": str, "blok": str, "umur_hari": int, "hasil_kg": float}
baris, ditolak = muat_csv(berkas, skema)
print(f"Diterima {len(baris)} baris, ditolak {ditolak}")
print("Total panen sah:", sum(b["hasil_kg"] for b in baris), "kg")

print("Berkas hilang:", muat_csv(tmp / "tidak_ada.csv", skema))

(tmp / "rusak.json").write_text('{"pohon": [1, 2,', encoding="utf-8")
print("JSON rusak -> cadangan:", muat_json(tmp / "rusak.json", cadangan={}))
WARNING: baris 6 di tanaman_kotor.csv ditolak (ValueError)
WARNING: baris 7 di tanaman_kotor.csv ditolak (TypeError)
Diterima 4 baris, ditolak 2
Total panen sah: 1030.0 kg
WARNING: berkas tidak_ada.csv belum ada
Berkas hilang: ([], 0)
WARNING: berkas rusak.json bukan JSON yang sah
JSON rusak -> cadangan: {}

Petugas menambahkan dua baris rusak: seratus tidak bisa menjadi int (ValueError), dan baris dengan kolom kurang membuat DictReader mengisi None, yang gagal diubah (TypeError). Keduanya ditolak dengan peringatan, empat baris sah tetap terbaca (total 1030,0 kg, cocok dengan Bab 11). Berkas hilang menghasilkan daftar kosong, dan JSON rusak menghasilkan nilai cadangan {}. Tidak ada satu pun yang meruntuhkan program: itulah arti “tahan terhadap input salah”.

Latihan

  1. Tulis data ternak ke ternak.csv, baca kembali dengan DictReader, dan cetak total bobot_kg (jangan lupa mengubah tipenya).
  2. Simpan dict {"nama": "Kolam Ø1", "suhu": "28 °C"} ke JSON dengan dan tanpa ensure_ascii=False. Bandingkan isi berkasnya.
  3. Ubah 08_proyek_simpan.py agar tinggi_m pohon dibulatkan ke bilangan bulat saat dimuat (ubah skemanya, bukan datanya).
  4. Tantangan: tulis fungsi bandingkan_csv_json(nama) yang memuat tabel nama dari CSV dan dari JSON, lalu melaporkan apakah keduanya sama. Apa yang terjadi bila ada nilai True yang disimpan lewat CSV tanpa pengubah bool_teks?

Ringkasan Bab

  • Serialisasi = mengubah data di memori menjadi teks/byte untuk disimpan; deserialisasi = sebaliknya.
  • CSV: tabel datar; buka dengan newline=""; semua nilai datang sebagai teks, ubah tipenya sendiri.
  • Jangan memotong CSV dengan split(","); pakai modul csv.
  • JSON: data bersarang; mengembalikan bool, angka, None, list, dict; tidak mengenal tuple dan tanggal.
  • Pickle tidak aman: membukanya bisa menjalankan kode. Jangan buka pickle dari sumber yang tidak Anda percayai.
  • Proyek B3: lima sektor tersimpan di CSV/JSON dan dimuat kembali dengan skema, tahan baris rusak, berkas hilang, dan JSON rusak.

Selanjutnya, di Bab 15: otomasi dokumen kantor, mengubah data tadi menjadi laporan Excel, Word, dan PDF.