API pada chapter sebelumnya menghadirkan data yang dimiliki pihak lain. Sekarang kita berbalik arah: data paling berharga di sebuah organisasi — transaksi kasir, data pelanggan, stok gudang — biasanya tersimpan di database relasional milik organisasi itu sendiri, dan analis dipersilakan mengambilnya langsung lewat bahasa SQL. Kabar baiknya, Python membawa modul sqlite3 di pustaka standar, sehingga kita tidak perlu memasang apa pun untuk mulai belajar. Pada chapter ini kita membuat database kecil, mengirim query SELECT, menjaga keamanan input dengan query berparameter, lalu membaca hasilnya ke pandas sebagai DataFrame.

Mengenal SQLite dan Modul sqlite3 #

SQLite adalah mesin database relasional yang menyimpan seluruh isi database dalam satu berkas biasa — tanpa server terpisah, tanpa proses instalasi. Sifat tersebut membuat SQLite cocok untuk latihan, purwarupa, dan aplikasi kecil, sekaligus menjadikannya pintu masuk yang ideal untuk belajar SQL dari Python. Modul sqlite3 sudah terpasang bersama Python, jadi cukup import sqlite3.

Database lain yang umum dijumpai di industri memakai pola kerja yang sama, hanya beda penggerak:

DatabasePenggerak PythonCatatan
SQLitesqlite3 (pustaka standar)Satu berkas, tanpa server
PostgreSQLpsycopgBerbasis server, skala besar
MySQL / MariaDBmysql-connector-pythonBerbasis server, banyak dipakai web

Ketiganya memakai alur yang serupa: buat koneksi, kirim query, ambil hasil, tutup koneksi. Setelah polanya kita kuasai di SQLite, berpindah penggerak hanya soal mengganti library dan alamat koneksi — itulah sebabnya chapter ini memusatkan demo pada sqlite3 tanpa memasang library eksternal.

Membuat Database Kecil #

Kita mulai dengan membangun database Toko Kopi Kita: tabel produk berisi daftar menu beserta harganya, dan tabel penjualan mencatat setiap transaksi. Database disimpan di dalam folder sementara hasil tempfile.TemporaryDirectory() agar folder chapter tetap bersih — folder tersebut beserta isinya terhapus otomatis ketika blok with selesai.

python
import sqlite3
import tempfile
from pathlib import Path

with tempfile.TemporaryDirectory() as folder_sementara:
    db_path = Path(folder_sementara) / "toko.db"

    koneksi = sqlite3.connect(db_path)
    koneksi.execute("""
        CREATE TABLE produk (
            id       INTEGER PRIMARY KEY,
            nama     TEXT,
            kategori TEXT,
            harga    INTEGER
        )
    """)
    koneksi.execute("INSERT INTO produk VALUES (1, 'Kopi Susu', 'Minuman', 24000)")
    koneksi.execute("INSERT INTO produk VALUES (2, 'Americano', 'Minuman', 22000)")
    koneksi.execute("INSERT INTO produk VALUES (3, 'Matcha Latte', 'Minuman', 28000)")
    koneksi.execute("INSERT INTO produk VALUES (4, 'Biji Kopi 200g', 'Biji', 85000)")

    koneksi.execute("""
        CREATE TABLE penjualan (
            id        INTEGER PRIMARY KEY,
            produk_id INTEGER,
            jumlah    INTEGER,
            tanggal   TEXT
        )
    """)
    data_penjualan = [
        (1, 1, 3, "2026-01-10"),
        (2, 2, 2, "2026-01-10"),
        (3, 3, 1, "2026-01-10"),
        (4, 1, 4, "2026-01-11"),
        (5, 4, 1, "2026-01-11"),
        (6, 2, 3, "2026-01-11"),
        (7, 1, 5, "2026-01-12"),
        (8, 3, 3, "2026-01-12"),
    ]
    koneksi.executemany("INSERT INTO penjualan VALUES (?, ?, ?, ?)", data_penjualan)
    koneksi.commit()

    print("Isi tabel produk:")
    for baris in koneksi.execute("SELECT * FROM produk"):
        print("  ", baris)
    print("Isi tabel penjualan:")
    for baris in koneksi.execute("SELECT * FROM penjualan"):
        print("  ", baris)

    koneksi.close()

print("Berkas database terhapus otomatis:", not db_path.exists())

Output yang Dihasilkan:

text
Isi tabel produk:
   (1, 'Kopi Susu', 'Minuman', 24000)
   (2, 'Americano', 'Minuman', 22000)
   (3, 'Matcha Latte', 'Minuman', 28000)
   (4, 'Biji Kopi 200g', 'Biji', 85000)
Isi tabel penjualan:
   (1, 1, 3, '2026-01-10')
   (2, 2, 2, '2026-01-10')
   (3, 3, 1, '2026-01-10')
   (4, 1, 4, '2026-01-11')
   (5, 4, 1, '2026-01-11')
   (6, 2, 3, '2026-01-11')
   (7, 1, 5, '2026-01-12')
   (8, 3, 3, '2026-01-12')
Berkas database terhapus otomatis: True

Mari kita telaah baris-baris pentingnya:

  1. Baris 8 (koneksi = sqlite3.connect(db_path)): koneksi dibuka ke berkas toko.db. Bila berkasnya belum ada, SQLite membuatnya; karena path berada di folder sementara, berkas tersebut hidup hanya selama blok with.
  2. Baris 9-20 (pembuatan tabel produk beserta isinya): pernyataan SQL dikirim lewat koneksi.execute(). Tabel didefinisikan dengan kolom dan tipe datanya, lalu diisi baris demi baris lewat INSERT.
  3. Baris 30-40 (data_penjualan dan executemany): delapan baris penjualan dimasukkan sekaligus. Tanda tanya pada "INSERT INTO penjualan VALUES (?, ?, ?, ?)" adalah slot yang diisi secara berurutan oleh setiap tuple — pola berparameter yang akan kita bahas mendalam sebentar lagi.
  4. Baris 41 (koneksi.commit()): perubahan data baru tersimpan permanen setelah di-commit. Tanpa baris ini, seluruh INSERT hilang saat koneksi ditutup.
  5. Baris 43-48 (dua loop SELECT *): hasil SELECT dapat diulang langsung; setiap baris hadir sebagai tuple Python. Perhatikan bahwa nilai teks tetap ditulis dengan tanda kutip satu, menandakan tipenya memang string.

Menanyakan Data dengan SELECT, WHERE, dan GROUP BY #

Pengambilan data yang sesungguhnya dilakukan lewat SELECT: memilih kolom dengan SELECT ... FROM, menyaring baris dengan WHERE, dan meringkas kelompok dengan GROUP BY. Agar blok-blok berikutnya fokus pada query, pembuatan database kita padatkan menjadi modul kecil siapkan_toko.py berisi satu fungsi yang menjalankan seluruh CREATE TABLE dan INSERT di atas — pola “modul sendiri” yang telah kita latih pada chapter Fungsi dan Modul.

python
"""Siapkan tabel produk dan penjualan beserta data contoh Toko Kopi Kita."""


def siapkan_toko(koneksi):
    """Buat tabel produk dan penjualan lalu mengisinya dengan data contoh."""
    koneksi.executescript("""
        CREATE TABLE produk (
            id       INTEGER PRIMARY KEY,
            nama     TEXT,
            kategori TEXT,
            harga    INTEGER
        );
        INSERT INTO produk VALUES
            (1, 'Kopi Susu', 'Minuman', 24000),
            (2, 'Americano', 'Minuman', 22000),
            (3, 'Matcha Latte', 'Minuman', 28000),
            (4, 'Biji Kopi 200g', 'Biji', 85000);
        CREATE TABLE penjualan (
            id        INTEGER PRIMARY KEY,
            produk_id INTEGER,
            jumlah    INTEGER,
            tanggal   TEXT
        );
        INSERT INTO penjualan VALUES
            (1, 1, 3, '2026-01-10'),
            (2, 2, 2, '2026-01-10'),
            (3, 3, 1, '2026-01-10'),
            (4, 1, 4, '2026-01-11'),
            (5, 4, 1, '2026-01-11'),
            (6, 2, 3, '2026-01-11'),
            (7, 1, 5, '2026-01-12'),
            (8, 3, 3, '2026-01-12');
    """)

Dengan modul tersebut di samping skrip kita, tiga bentuk query inti dapat dicoba:

python
import sqlite3
import tempfile
from pathlib import Path

from siapkan_toko import siapkan_toko

with tempfile.TemporaryDirectory() as folder_sementara:
    koneksi = sqlite3.connect(Path(folder_sementara) / "toko.db")
    siapkan_toko(koneksi)

    print("Semua produk:")
    for baris in koneksi.execute("SELECT * FROM produk"):
        print("  ", baris)

    print("Minuman dengan harga di bawah 25000:")
    for baris in koneksi.execute(
        "SELECT nama, harga FROM produk"
        " WHERE kategori = 'Minuman' AND harga < 25000"
    ):
        print("  ", baris)

    print("Total unit terjual per produk:")
    for baris in koneksi.execute(
        "SELECT produk.nama, SUM(penjualan.jumlah) AS total"
        " FROM penjualan"
        " JOIN produk ON produk.id = penjualan.produk_id"
        " GROUP BY produk.nama"
        " ORDER BY total DESC"
    ):
        print("  ", baris)

    koneksi.close()

Output yang Dihasilkan:

text
Semua produk:
   (1, 'Kopi Susu', 'Minuman', 24000)
   (2, 'Americano', 'Minuman', 22000)
   (3, 'Matcha Latte', 'Minuman', 28000)
   (4, 'Biji Kopi 200g', 'Biji', 85000)
Minuman dengan harga di bawah 25000:
   ('Kopi Susu', 24000)
   ('Americano', 22000)
Total unit terjual per produk:
   ('Kopi Susu', 12)
   ('Americano', 5)
   ('Matcha Latte', 4)
   ('Biji Kopi 200g', 1)

Tiga query di atas mewakili tiga tingkat kerumitan yang makin sering kita pakai:

  1. SELECT * FROM produk: mengambil seluruh kolom dari seluruh baris — bentuk paling sederhana, cocok untuk memeriksa isi tabel.
  2. SELECT nama, harga ... WHERE ... AND ...: memilih kolom tertentu dan menyaring baris dengan syarat gabungan. Hanya Kopi Susu dan Americano yang lolos saringan kategori Minuman sekaligus harga di bawah 25000.
  3. SELECT ... JOIN ... GROUP BY ... ORDER BY ...: menggabungkan dua tabel lewat JOIN, meringkas tiap kelompok dengan SUM(), lalu mengurutkan hasilnya. Query ketiga menjawab pertanyaan analisis sungguhan — “produk mana yang paling laku?” — langsung di dalam database, sebelum satu baris pun disentuh pandas.

Query Berparameter untuk Keamanan Input #

Dalam program nyata, nilai pada query sering berasal dari pengguna: kategori yang dipilih di menu, nama produk yang diketik, rentang tanggal dari formulir. Kebiasaan yang wajib kita bentuk adalah mengirim nilai tersebut sebagai parameter dengan tanda tanya, bukan menempelnya ke string SQL. Blok berikut memperlihatkan mengapa hal tersebut penting — sebuah input yang sengaja dibuat berbahaya berisi tanda kutip dan perbandingan yang selalu benar.

python
import sqlite3
import tempfile
from pathlib import Path

from siapkan_toko import siapkan_toko

with tempfile.TemporaryDirectory() as folder_sementara:
    koneksi = sqlite3.connect(Path(folder_sementara) / "toko.db")
    siapkan_toko(koneksi)

    kategori = "Minuman"
    hasil = koneksi.execute(
        "SELECT nama, harga FROM produk WHERE kategori = ?",
        (kategori,),
    ).fetchall()
    print("Kategori normal, query berparameter:", hasil)

    masukan_berbahaya = "' OR '1'='1"
    hasil_aman = koneksi.execute(
        "SELECT nama, harga FROM produk WHERE kategori = ?",
        (masukan_berbahaya,),
    ).fetchall()
    print("Input berbahaya, query berparameter:", hasil_aman)

    hasil_rawan = koneksi.execute(
        f"SELECT nama, harga FROM produk WHERE kategori = '{masukan_berbahaya}'"
    ).fetchall()
    print("Input berbahaya, query f-string      :", hasil_rawan)

    koneksi.close()

Output yang Dihasilkan:

text
Kategori normal, query berparameter: [('Kopi Susu', 24000), ('Americano', 22000), ('Matcha Latte', 28000)]
Input berbahaya, query berparameter: []
Input berbahaya, query f-string      : [('Kopi Susu', 24000), ('Americano', 22000), ('Matcha Latte', 28000), ('Biji Kopi 200g', 85000)]

Perbandingan dua baris terakhir adalah pelajaran keamanan yang penting:

  1. Query berparameter (?): nilai pengguna dikirim terpisah dari teks SQL, sehingga tanda kutip pada input hanya dibaca sebagai teks biasa. Input berbahaya menghasilkan daftar kosong — persis perilaku yang kita harapkan dari saringan kategori yang tidak cocok.
  2. Query f-string: teks input langsung menempel pada string SQL. Kutip penutup membuat kondisi pertama berhenti, lalu '1'='1' selalu bernilai benar, akibatnya saringan kita diloloskan dan seluruh baris tabel bocor keluar. Pada database produksi, pola serangan yang sama dapat membaca atau mengubah data yang seharusnya terlindungi.

Membaca Hasil Query ke pandas #

Titik temu database dan analisis data ada di sini: hasil fetchall() berupa list of tuples — struktur yang dibahas pada tutorial struktur data Python — dan struktur tersebut dapat langsung dibungkus pd.DataFrame() dengan nama kolom yang kita tetapkan, sama seperti saat kita pertama kali membentuk DataFrame pada tutorial intro pandas .

python
import sqlite3
import tempfile
from pathlib import Path

import pandas as pd

from siapkan_toko import siapkan_toko

with tempfile.TemporaryDirectory() as folder_sementara:
    db_path = Path(folder_sementara) / "toko.db"
    with sqlite3.connect(db_path) as koneksi:
        siapkan_toko(koneksi)
        hasil = koneksi.execute(
            "SELECT produk.nama, SUM(penjualan.jumlah * produk.harga) AS pendapatan"
            " FROM penjualan"
            " JOIN produk ON produk.id = penjualan.produk_id"
            " GROUP BY produk.nama"
            " ORDER BY pendapatan DESC"
        ).fetchall()
    koneksi.close()

print("Hasil mentah (list of tuples):")
print(hasil)

df = pd.DataFrame(hasil, columns=["nama", "pendapatan"])
print()
print(df)
print()
print("Total pendapatan:", df["pendapatan"].sum())

Output yang Dihasilkan:

text
Hasil mentah (list of tuples):
[('Kopi Susu', 288000), ('Matcha Latte', 112000), ('Americano', 110000), ('Biji Kopi 200g', 85000)]

             nama  pendapatan
0       Kopi Susu      288000
1    Matcha Latte      112000
2       Americano      110000
3  Biji Kopi 200g       85000

Total pendapatan: 595000

Ada tiga hal baru pada blok di atas:

  1. Baris 11 (with sqlite3.connect(db_path) as koneksi): memakai koneksi sebagai konteks with menjamin perubahan di-commit saat blok selesai (atau dibatalkan bila terjadi galat). Perlu dicatat, keluar dari with tidak menutup koneksinya — karena itu baris koneksi.close() tetap dipanggil setelahnya agar sumber daya dilepas.
  2. Baris 13-19 (query agregat): database yang menghitung SUM(jumlah * harga) per produk, sehingga Python hanya menerima empat baris hasil yang ringkas. Prinsip umumnya: biarkan database mengerjakan pemeringkatan dan peringkasan yang menjadi keahliannya, lalu pandas meneruskan analisisnya.
  3. Baris 25 (pd.DataFrame(hasil, columns=[...])): tuple pertama menjadi baris pertama, dan columns memberi nama pada tiap kolom. Setelahnya seluruh peralatan pandas siap dipakai, mulai dari df["pendapatan"].sum() sampai visualisasi pada chapter-chapter berikutnya.

Rangkuman #

  • sqlite3 dari pustaka standar menghubungkan Python ke database SQLite dalam satu berkas; PostgreSQL dan MySQL memakai penggerak terpisah dengan pola kerja yang sama.
  • Alur dasarnya: connect() → execute() untuk SQL → fetchall() menghasilkan list of tuples → close().
  • SELECT memilih kolom, WHERE menyaring baris, JOIN menggabungkan tabel, dan GROUP BY meringkas kelompok — biarkan database mengerjakan peringkasan berat.
  • Nilai dari pengguna selalu dikirim lewat tanda tanya, jangan pernah ditempel ke string SQL dengan f-string.
  • pd.DataFrame(hasil, columns=[...]) menjembatani hasil query ke seluruh peralatan analisis pandas.

Database menyimpan data yang memang disiapkan untuk dibaca. Namun kadang data yang kita butuhkan hanya tersaji sebagai halaman web untuk manusia — daftar harga di sebuah situs, jadwal di sebuah portal. Chapter berikutnya membahas cara mengambil data dari halaman HTML secara etis dan terkendali di Pengantar Web Scraping.