Chapter 24 — Databases: A Filing Cabinet That Never Forgets

Series I2 — Data and the Internet

NoteIn this chapter, you will:
  • Understand what a database, a table, a row, and SQL are
  • Use SQLite through Python’s built-in sqlite3 module
  • Write INSERT, SELECT, UPDATE, DELETE, JOIN, and GROUP BY, and avoid SQL injection
  • Meet SQLAlchemy (Core and ORM) and how to connect to PostgreSQL
  • Store the merged data of Project I2 in a database

All scripts for this chapter are in the kode/ch24/ folder. On the web version, you can also download them all at once (zip).

A CSV file (Chapter 23) is like a pile of note papers: easy to make, but hard to search, and it can turn into a mess when two people write at the same time. A database is a filing cabinet with drawers: each drawer (table) has fixed columns, each sheet (row) follows the rules, and the filing clerk (SQL, Structured Query Language) answers questions like “what is the total plantation harvest?” without you turning over every sheet. It also keeps storage safe: a change that is half done can be cancelled.

Three tables (harvest, weather, prices) linked through shared columns. This is the heart of a relational database.

SQLite: a database in one file

SQLite is a database engine that lives as one file and comes with Python (the sqlite3 module), so there is nothing to install and no server. It suits learning, small applications, and data you can carry around.

01_sqlite_basics.py
# Chapter 24 - SQLite with the sqlite3 module (built into Python)
import sqlite3
from pathlib import Path

tmp = Path(__file__).parent / "_tmp"
tmp.mkdir(exist_ok=True)
db_file = tmp / "field.db"
db_file.unlink(missing_ok=True)                # start from an empty database so the result is the same

connection = sqlite3.connect(db_file)          # the file is created if it does not exist
connection.execute("""
    CREATE TABLE tree (
        id INTEGER PRIMARY KEY,
        species TEXT NOT NULL,
        plot TEXT NOT NULL,
        diameter_cm REAL
    )
""")
data = [("Teak", "A", 32.5), ("Meranti", "A", 45.2), ("Mahogany", "B", 28.0), ("Teak", "B", 36.1)]
connection.executemany("INSERT INTO tree (species, plot, diameter_cm) VALUES (?, ?, ?)", data)
connection.commit()                            # without commit, changes are not saved permanently

for row in connection.execute("SELECT id, species, plot, diameter_cm FROM tree ORDER BY diameter_cm DESC"):
    print(row)

count = connection.execute("SELECT COUNT(*) FROM tree").fetchone()[0]
print("Number of trees:", count)
connection.close()
(2, 'Meranti', 'A', 45.2)
(4, 'Teak', 'B', 36.1)
(1, 'Teak', 'A', 32.5)
(3, 'Mahogany', 'B', 28.0)
Number of trees: 4
  • CREATE TABLE defines the columns and their kinds; PRIMARY KEY = a unique number for each row; NOT NULL = must be filled.
  • ? is a value placeholder; the values are given separately. executemany inserts many rows at once.
  • commit() saves the changes permanently. Without a commit, changes may be lost.
  • ORDER BY diameter_cm DESC sorts from large to small: Meranti 45.2 is on top.

Never paste user text into SQL

02_safe_parameters.py
# Chapter 24 - Parameters (?) against SQL injection
import sqlite3

connection = sqlite3.connect(":memory:")       # a temporary database in memory
connection.execute("CREATE TABLE officer (name TEXT, area TEXT)")
connection.executemany("INSERT INTO officer VALUES (?, ?)", [("Sari", "Plot A"), ("Budi", "Plot B")])

user_input = "x' OR '1'='1"                     # a mischievous entry from a user

# WRONG WAY: pasting user text straight into the SQL (f-string)
bad_sql = f"SELECT name FROM officer WHERE name = '{user_input}'"
print("SQL that was built:", bad_sql)
print("Wrong way result  :", connection.execute(bad_sql).fetchall())      # leak: every row comes out!

# RIGHT WAY: question mark + separate value; the text is treated as DATA, not as a command
print("Right way result  :", connection.execute("SELECT name FROM officer WHERE name = ?", (user_input,)).fetchall())
SQL that was built: SELECT name FROM officer WHERE name = 'x' OR '1'='1'
Wrong way result  : [('Sari',), ('Budi',)]
Right way result  : []
WarningWARNING

In the wrong way, the mischievous entry x' OR '1'='1 becomes part of the SQL command and makes the condition always true, so every row leaks. This is called SQL injection, one of the most common attacks in the world. The right way: always use ? (or :name) and give the values separately; the text is treated as data, never as a command. The result: no row matches.

Update, delete, and transactions

03_update_delete_transaction.py
# Chapter 24 - UPDATE, DELETE, and transactions (commit / rollback)
import sqlite3

connection = sqlite3.connect(":memory:")
connection.execute("CREATE TABLE pond (name TEXT PRIMARY KEY, fish INTEGER CHECK (fish >= 0))")
connection.executemany("INSERT INTO pond VALUES (?, ?)", [("A", 800), ("B", 650), ("C", 400)])
connection.commit()

connection.execute("UPDATE pond SET fish = fish - 50 WHERE name = 'A'")     # 50 fish harvested
connection.execute("DELETE FROM pond WHERE name = 'C'")                     # pond C dismantled
print(connection.execute("SELECT * FROM pond ORDER BY name").fetchall())
connection.commit()                                 # save first: a safe point before the next attempt

# Transaction: all steps succeed together, or are cancelled together
try:
    with connection:                                # automatic commit; rollback on an error
        connection.execute("UPDATE pond SET fish = fish - 100 WHERE name = 'B'")
        connection.execute("UPDATE pond SET fish = fish - 900 WHERE name = 'A'")   # breaks the CHECK
except sqlite3.IntegrityError as error:
    print("Cancelled:", error)

print("After rollback:", connection.execute("SELECT * FROM pond ORDER BY name").fetchall())
[('A', 750), ('B', 650)]
Cancelled: CHECK constraint failed: fish >= 0
After rollback: [('A', 750), ('B', 650)]

After harvesting 50 fish, pond A has 750 left; pond C is dismantled. Then comes a transaction: two changes that must succeed together. The second change (take 900 fish from A) breaks the rule CHECK (fish >= 0), so SQLite refuses with an IntegrityError, and the with connection: block cancels both changes (rollback): pond B stays at 650, not 550. That is the use of a transaction: no half-finished state.

NoteREMEMBER

A database can guard data rules for you through constraints (NOT NULL, UNIQUE, CHECK, PRIMARY KEY, FOREIGN KEY). Let the database refuse bad data; do not rely on Python code alone.

Two tables: JOIN and GROUP BY

04_join_aggregate.py
# Chapter 24 - Two tables: JOIN and GROUP BY
import sqlite3

connection = sqlite3.connect(":memory:")
connection.executescript("""
    CREATE TABLE sector (id INTEGER PRIMARY KEY, name TEXT);
    CREATE TABLE harvest (id INTEGER PRIMARY KEY, sector_id INTEGER REFERENCES sector(id),
                          commodity TEXT, yield_kg REAL);
    INSERT INTO sector VALUES (1, 'Plantation'), (2, 'Cropland'), (3, 'Fisheries');
    INSERT INTO harvest (sector_id, commodity, yield_kg) VALUES
        (1, 'coffee', 245.5), (1, 'cocoa', 198.5), (1, 'coffee', 310.0),
        (2, 'rice', 3100.0), (2, 'maize', 1850.0), (3, 'catfish', 432.4);
""")

sql = """
    SELECT sector.name AS sector, COUNT(*) AS harvest_count, ROUND(SUM(harvest.yield_kg), 1) AS total_kg
    FROM harvest
    JOIN sector ON sector.id = harvest.sector_id
    GROUP BY sector.name
    HAVING SUM(harvest.yield_kg) > 500
    ORDER BY total_kg DESC
"""
for row in connection.execute(sql):
    print(row)
('Cropland', 2, 4950.0)
('Plantation', 3, 754.0)

JOIN connects the rows of two tables where sector_id equals the sector’s id, so the sector name is shown. GROUP BY groups and SUM/COUNT summarise, exactly like pandas groupby in Chapter 23. HAVING filters after grouping (only sectors with a total above 500 kg), whereas WHERE filters before. Cropland: 2 harvests, 4950.0 kg (3100 + 1850); Plantation: 3 harvests, 754.0 kg; Fisheries is dropped by HAVING because it has only 432.4 kg.

pandas and SQL

05_pandas_and_sql.py
# Chapter 24 - pandas and SQL exchange data
import sqlite3
import pandas as pd

connection = sqlite3.connect(":memory:")

ponds = pd.DataFrame({"pond": ["A", "B", "C"], "species": ["catfish", "tilapia", "catfish"],
                      "fish": [800, 650, 400], "weight_g": [540.5, 320.0, 480.0]})
ponds.to_sql("pond", connection, index=False)             # DataFrame -> SQL table

result = pd.read_sql_query(
    "SELECT species, SUM(fish) AS total_fish, ROUND(AVG(weight_g), 1) AS avg_weight_g FROM pond GROUP BY species",
    connection,
)                                                         # SQL result -> DataFrame
print(result)

heavy = pd.read_sql_query("SELECT * FROM pond WHERE weight_g > ?", connection, params=(450,))
print(heavy)
   species  total_fish  avg_weight_g
0  catfish        1200         510.3
1  tilapia         650         320.0
  pond  species  fish  weight_g
0    A  catfish   800     540.5
1    C  catfish   400     480.0

to_sql turns a DataFrame into a table; read_sql_query turns the result of a SELECT into a DataFrame. The two tools are friends: SQL is good for sifting large data inside the database, pandas is good for analysing the result.

SQLAlchemy: one way into many databases

Writing SQL directly ties you to one brand of database. SQLAlchemy is a library (pip install sqlalchemy) that acts as a translator: the same code can run on SQLite or PostgreSQL, you only change the connection address (URL) in create_engine. It has two layers.

Core: you still write SQL, but with safe named parameters :name and tidy connection handling (engine.begin() = automatic commit at the end of the block):

06_sqlalchemy_core.py
# Chapter 24 - SQLAlchemy: engine and text() (the "Core" layer)
from pathlib import Path
from sqlalchemy import create_engine, text

tmp = Path(__file__).parent / "_tmp"
tmp.mkdir(exist_ok=True)
db_file = tmp / "sqlalchemy_core.db"
db_file.unlink(missing_ok=True)

engine = create_engine(f"sqlite:///{db_file.as_posix()}")       # URL: database_kind:///path

with engine.begin() as connection:                               # begin(): automatic commit at the end of the block
    connection.execute(text("CREATE TABLE livestock (barn TEXT, species TEXT, weight_kg REAL)"))
    connection.execute(
        text("INSERT INTO livestock VALUES (:barn, :species, :weight)"),     # :name = named parameter
        [{"barn": "K1", "species": "cattle", "weight": 350.0},
         {"barn": "K1", "species": "cattle", "weight": 412.5},
         {"barn": "K2", "species": "cattle", "weight": 389.0}],
    )

with engine.connect() as connection:
    result = connection.execute(text("SELECT barn, AVG(weight_kg) AS mean FROM livestock GROUP BY barn"))
    for row in result:
        print(row.barn, round(row.mean, 2))

print("dialect:", engine.dialect.name)
engine.dispose()                                                  # release the file
K1 381.25
K2 389.0
dialect: sqlite

ORM (Object-Relational Mapper): tables are written as Python classes (remember OOP, Chapter 16), rows become objects, and relationships between tables become attributes:

07_sqlalchemy_orm.py
# Chapter 24 - SQLAlchemy ORM: tables as Python classes
from pathlib import Path
from sqlalchemy import ForeignKey, create_engine, func, select
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column, relationship

tmp = Path(__file__).parent / "_tmp"
tmp.mkdir(exist_ok=True)
db_file = tmp / "sqlalchemy_orm.db"
db_file.unlink(missing_ok=True)


class Base(DeclarativeBase):
    pass


class Sector(Base):
    __tablename__ = "sector"
    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str]
    harvests: Mapped[list["Harvest"]] = relationship(back_populates="sector")


class Harvest(Base):
    __tablename__ = "harvest"
    id: Mapped[int] = mapped_column(primary_key=True)
    commodity: Mapped[str]
    yield_kg: Mapped[float]
    sector_id: Mapped[int] = mapped_column(ForeignKey("sector.id"))
    sector: Mapped[Sector] = relationship(back_populates="harvests")


engine = create_engine(f"sqlite:///{db_file.as_posix()}")
Base.metadata.create_all(engine)                    # creates the tables from the class definitions

with Session(engine) as session:
    plantation = Sector(name="Plantation", harvests=[Harvest(commodity="coffee", yield_kg=245.5),
                                                     Harvest(commodity="cocoa", yield_kg=198.5)])
    cropland = Sector(name="Cropland", harvests=[Harvest(commodity="rice", yield_kg=3100.0)])
    session.add_all([plantation, cropland])
    session.commit()

with Session(engine) as session:
    for sector in session.scalars(select(Sector).order_by(Sector.name)):
        print(sector.name, "->", [h.commodity for h in sector.harvests])        # relationship, automatically

    biggest = session.scalars(select(Harvest).order_by(Harvest.yield_kg.desc())).first()
    print("Biggest harvest:", biggest.commodity, biggest.yield_kg, "kg from", biggest.sector.name)
    print("Total kg:", session.scalar(select(func.sum(Harvest.yield_kg))))
engine.dispose()
Cropland -> ['rice']
Plantation -> ['coffee', 'cocoa']
Biggest harvest: rice 3100.0 kg from Cropland
Total kg: 3544.0

Sector.harvests holds a list of Harvest objects without you writing a JOIN; in the other direction biggest.sector.name travels upward. The total 3544.0 kg = 245.5 + 198.5 + 3100.0.

PostgreSQL: a server-level database

PostgreSQL is a database that runs as a server used by many people at once, with far richer features (users and permissions, large capacity, extensions such as PostGIS for maps in Series A2). With SQLAlchemy, the biggest difference is simply the connection address, shaped like postgresql+psycopg://USER:PASSWORD@SERVER:5432/DATABASE_NAME, which uses the psycopg version 3 library (pip install "psycopg[binary]").

08_postgresql.py
# Chapter 24 - Connecting to PostgreSQL (a temporary server started by the book-building tool)
import os
from sqlalchemy import create_engine, text

# The connection address is read from an environment variable, so the password is not written in code.
# The shape of the address on your own server:
#   postgresql://USER:PASSWORD@SERVER:5432/DATABASE_NAME
# Example value of the variable (password masked):
#   DATABASE_URL = postgresql://officer:[disamarkan]@localhost:5432/forest_farm
address = os.environ["DATABASE_URL"]

# SQLAlchemy picks the driver from the start of the address; "+psycopg" asks for psycopg version 3.
address = address.replace("postgresql://", "postgresql+psycopg://", 1)
engine = create_engine(address)

with engine.begin() as connection:
    connection.execute(text("""
        CREATE TABLE IF NOT EXISTS harvest (
            id SERIAL PRIMARY KEY,
            commodity TEXT NOT NULL,
            yield_kg NUMERIC(10, 1)
        )
    """))
    connection.execute(text("INSERT INTO harvest (commodity, yield_kg) VALUES (:c, :y)"), {"c": "coffee", "y": 245.5})

with engine.connect() as connection:
    print(connection.execute(text("SELECT commodity, yield_kg FROM harvest")).fetchall())
[('coffee', Decimal('245.5'))]

This code was really run when the book was built, on a temporary PostgreSQL 14.5 started by the book-building tool: that server lives only while the book is built, can only be reached from the author’s computer, has no password, and is then deleted. A reader’s or an office’s own server usually has a user name and password; that part was not tested here. Notice two things in the output. First, the NUMERIC column comes back as Decimal('245.5'), not a float as in SQLite. Second, the line address.replace(...) adds +psycopg to the start of the address so SQLAlchemy uses the psycopg 3 driver.

To try it on your own computer, set the environment variable first (a PowerShell example, password masked): $env:DATABASE_URL = "postgresql://officer:[disamarkan]@localhost:5432/forest_farm". Notice two safe habits: the address is read from the environment variable DATABASE_URL (no password in code), and the example value is written [disamarkan] (masked).

Differences between SQLite and PostgreSQL you will meet: PostgreSQL is strict about column types (a NUMERIC column comes back as Decimal, not float; this is proven by the output above), uses SERIAL or GENERATED ... AS IDENTITY for automatic numbers (SERIAL is used and works in the example), and a table created inside a transaction becomes visible to others after the commit. SQLite is looser about column types. That is why you test on the engine you will really use. (The points about GENERATED ... AS IDENTITY and transaction visibility are general knowledge that has not been tested in this book.)

Running Project I2: storing the merged data

Step 4: the fictional data is stored as three separate tables (harvest, weather, prices) in an SQLite file, like a real database, then one SQL query with two LEFT JOINs computes the total yield, income, and mean rain per sector.

09_project_save_db.py
# Chapter 24 - Running project I2, step 4: saving the merged data in a database
from pathlib import Path
import pandas as pd
from sqlalchemy import create_engine, text

tmp = Path(__file__).parent / "_tmp"
tmp.mkdir(exist_ok=True)
db_file = tmp / "project_i2.db"
db_file.unlink(missing_ok=True)

# The (fictional) data is copied so the script stands on its own; same shape as Chapters 21 and 23.
harvest = pd.DataFrame(
    [("2026-01-01", "Forestry", "Plot A", "teak", 1200.0), ("2026-01-01", "Plantation", "Block A", "coffee", 245.5),
     ("2026-01-02", "Plantation", "Block B", "cocoa", 198.5), ("2026-01-02", "Cropland", "Paddy 1", "rice", 3100.0),
     ("2026-01-03", "Cropland", "Paddy 2", "maize", 1850.0), ("2026-01-03", "Fisheries", "Pond A", "catfish", 432.4),
     ("2026-01-04", "Livestock", "Barn 1", "cattle", 1151.5), ("2026-01-04", "Plantation", "Block C", "coffee", 310.0),
     ("2026-01-05", "Cropland", "Paddy 1", "rice", 2950.0), ("2026-01-05", "Plantation", "Block D", "cocoa", 276.0)],
    columns=["date", "sector", "unit", "commodity", "yield_kg"])
dates = ["2026-01-01", "2026-01-02", "2026-01-03", "2026-01-04", "2026-01-05"]
weather = pd.DataFrame({"date": dates, "max_temp": [30.1, 31.4, 29.8, 28.9, 30.6],
                        "rain_mm": [12.5, 0.0, 8.2, 25.4, 3.1]})
price_lists = {"rice": [6200, 6250, 6180, 6300, 6270], "maize": [5100, 5150, 5200, 5120, 5180],
               "coffee": [58000, 58500, 59000, 57500, 58200], "cocoa": [62000, 61500, 62500, 63000, 62800]}
prices = pd.DataFrame([(d, c, p) for c, v in price_lists.items() for d, p in zip(dates, v)],
                      columns=["date", "commodity", "price_per_kg"])

engine = create_engine(f"sqlite:///{db_file.as_posix()}")
harvest.to_sql("harvest", engine, index=False)     # three separate tables, like a real database
weather.to_sql("weather", engine, index=False)
prices.to_sql("prices", engine, index=False)

sql = text("""
    SELECT h.sector,
           ROUND(SUM(h.yield_kg), 1) AS yield_kg,
           ROUND(SUM(h.yield_kg * p.price_per_kg), 0) AS income_rp,
           ROUND(AVG(w.rain_mm), 1) AS mean_rain_mm
    FROM harvest h
    LEFT JOIN prices p ON p.date = h.date AND p.commodity = h.commodity
    LEFT JOIN weather w ON w.date = h.date
    GROUP BY h.sector
    ORDER BY h.sector
""")
with engine.connect() as connection:
    for table in ["harvest", "weather", "prices"]:
        n = connection.execute(text(f"SELECT COUNT(*) FROM {table}")).scalar()
        print(f"table {table:7}: {n} rows")
    print(pd.read_sql_query(sql, connection).to_string(index=False))
engine.dispose()
table harvest: 10 rows
table weather: 5 rows
table prices : 20 rows
    sector  yield_kg  income_rp  mean_rain_mm
  Cropland    7900.0 47491500.0           3.8
 Fisheries     432.4        NaN           8.2
  Forestry    1200.0        NaN          12.5
 Livestock    1151.5        NaN          25.4
Plantation    1030.0 61604550.0          10.3

The result agrees with pandas in Chapter 23: Plantation 1030.0 kg and Rp 61,604,550; Cropland 7900.0 kg and Rp 47,491,500. Sectors without a price show NaN for income because a SUM over empty values stays empty. Unlike pandas, which sums empties to 0. Mean rain per sector is the mean of the rain on that sector’s harvest days (not rain for a month), so interpret it with care.

Exercises

  1. In 01_sqlite_basics.py, add one new tree, then print only the trees with plot = 'B' (use WHERE plot = ?).
  2. In 03_update_delete_transaction.py, change the deduction from A to 100 so it does not break the rule. What happens to the whole transaction?
  3. In 04_join_aggregate.py, remove the HAVING and add ORDER BY sector.name. Which sectors appear?
  4. Challenge: in 07_sqlalchemy_orm.py, add the sector “Fisheries” with one harvest “catfish” 432.4 kg, then print the total yield per sector with func.sum and group_by.

Chapter Summary

  • A database = a filing cabinet with drawers: tables, rows, columns; SQL = the language to ask and change.
  • SQLite = one file, built into Python (sqlite3); save changes with commit().
  • Always use parameters (?, :name), never paste user text into SQL (SQL injection).
  • A transaction makes a group of changes succeed together or cancel together; constraints (CHECK, NOT NULL, …) guard data quality.
  • JOIN connects tables; GROUP BY + SUM/COUNT summarise; WHERE filters before, HAVING after grouping.
  • SQLAlchemy (Core and ORM) unifies access to many databases; PostgreSQL only needs a different connection address, and that part is run in this book on a temporary server (your own server still needs a user name and password).

Next, in Chapter 25: the data is stored neatly. Time to analyse it: advanced pandas, numpy, matplotlib, scikit-learn, and Jupyter Notebook, then closing Series I2.