Chapter 55, Backend
PostgreSQL and SQL
The database is, almost always, the most enduring part of a system. I teach SQL with SQLite, which comes with Python and requires no installation, and then I show what changes in PostgreSQL.
Code for this chapter: backend/cap55_sql.py
Why SQL, and why PostgreSQL
SQL is the declarative language that describes what you want, and the database decides how to fetch. PostgreSQL is the relational database I choose by default: it is open, extremely reliable, has rich types (JSON, arrays, dates with timezone) and solid transactions. The concepts in this chapter apply to any relational database. SQLite is useful for learning and for quick tests, and I show where PostgreSQL differs.
Tables and constraints
Constraints (NOT NULL, UNIQUE, CHECK, REFERENCES) make the database reject invalid data, even if a bug in the code tries to write it. I always declare them, because the database is the last line of defense:
import sqlite3
conexao = sqlite3.connect(":memory:")
conexao.row_factory = sqlite3.Row
conexao.execute("PRAGMA foreign_keys = ON")
conexao.executescript("""
CREATE TABLE clientes (
id INTEGER PRIMARY KEY,
nome TEXT NOT NULL,
cidade TEXT
);
CREATE TABLE pedidos (
id INTEGER PRIMARY KEY,
cliente_id INTEGER NOT NULL REFERENCES clientes(id),
total_centavos INTEGER NOT NULL CHECK (total_centavos > 0)
);
INSERT INTO clientes (nome, cidade) VALUES ('Ana', 'Recife'), ('Bia', 'Natal'), ('Caio', 'Recife');
INSERT INTO pedidos (cliente_id, total_centavos) VALUES (1, 5000), (1, 2500), (2, 10000);
""")
Monetary values are kept in whole cents (INTEGER) or in NUMERIC, never in FLOAT, for the same reason as the types chapter.
Querying: SELECT, JOIN and aggregation
consulta = "SELECT nome FROM clientes WHERE cidade = 'Recife' ORDER BY nome"
print([linha["nome"] for linha in conexao.execute(consulta)])
juncao = """
SELECT c.nome, p.total_centavos
FROM pedidos p
JOIN clientes c ON c.id = p.cliente_id
ORDER BY p.id
"""
print([tuple(linha) for linha in conexao.execute(juncao)])
['Ana', 'Caio']
[('Ana', 5000), ('Ana', 2500), ('Bia', 10000)]
The JOIN (or INNER JOIN) only returns customers who have an order. The LEFT JOIN keeps all customers, and those without an order appear with NULL. The GROUP BY groups rows and the HAVING filters the groups (the WHERE filters rows before grouping):
agregacao = """
SELECT c.nome, COALESCE(SUM(p.total_centavos), 0) AS total
FROM clientes c
LEFT JOIN pedidos p ON p.cliente_id = c.id
GROUP BY c.nome
ORDER BY c.nome
"""
print([tuple(linha) for linha in conexao.execute(agregacao)])
com_filtro = """
SELECT cliente_id, COUNT(*) AS quantidade
FROM pedidos
GROUP BY cliente_id
HAVING COUNT(*) > 1
"""
print([tuple(linha) for linha in conexao.execute(com_filtro)])
[('Ana', 7500), ('Bia', 10000), ('Caio', 0)]
[(1, 2)]
The COALESCE replaces Caio's NULL (without orders) with zero. Without the LEFT JOIN, he wouldn't even appear in the result.
Parameters: never build SQL with text
Building SQL with f-string or concatenation is the oldest and still most common vulnerability: SQL injection. User input becomes part of the command. The parameters send the value separate from the command, and the database never interprets it as SQL:
entrada = "x' OR '1'='1"
inseguro = conexao.execute(f"SELECT nome FROM clientes WHERE nome = '{entrada}'").fetchall()
seguro = conexao.execute("SELECT nome FROM clientes WHERE nome = ?", (entrada,)).fetchall()
print(len(inseguro), len(seguro))
3 0
The f-string version returned all the customers (3), because the input rewrote the condition. The parameter version searched for a literally strange name and found zero. The placeholder is ? in SQLite and %s in the psycopg, but the rule is the same in any driver.
Transactions: all or nothing
A transaction groups operations that need to happen together. Transferring money is the classic example: debiting from one account and crediting to another cannot be left halfway. The with conexao confirms (commit) if the block ends well and undoes (rollback) if an exception is raised:
conexao.execute("CREATE TABLE contas (id INTEGER PRIMARY KEY, saldo INTEGER NOT NULL CHECK (saldo >= 0))")
conexao.executemany("INSERT INTO contas VALUES (?, ?)", [(1, 100), (2, 50)])
conexao.commit()
def transferir(origem, destino, valor):
with conexao:
conexao.execute("UPDATE contas SET saldo = saldo - ? WHERE id = ?", (valor, origem))
conexao.execute("UPDATE contas SET saldo = saldo + ? WHERE id = ?", (valor, destino))
transferir(1, 2, 30)
try:
transferir(1, 2, 500)
except sqlite3.IntegrityError as erro:
print("recusado:", erro)
print([tuple(linha) for linha in conexao.execute("SELECT id, saldo FROM contas ORDER BY id")])
recusado: CHECK constraint failed: saldo >= 0
[(1, 70), (2, 80)]
The second transfer failed on the debit (negative balance violates the CHECK), and the rollback ensured that nothing changed: the balances are 70 and 80, which sum to the same 150 as before. These guarantees (atomicity, consistency, isolation, and durability) are the ACID.
Indexes and the query plan
Without an index, searching for a column forces the database to read the entire table. An index is an auxiliary ordered structure that makes the search fast, at the cost of space and slightly slower writes. The command EXPLAIN shows how the database intends to execute the query:
def usa_indice(sql):
plano = conexao.execute("EXPLAIN QUERY PLAN " + sql).fetchall()
return any("INDEX" in linha["detail"] for linha in plano)
busca = "SELECT * FROM pedidos WHERE cliente_id = 1"
print("antes do índice:", usa_indice(busca))
conexao.execute("CREATE INDEX ix_pedidos_cliente ON pedidos (cliente_id)")
print("depois do índice:", usa_indice(busca))
antes do índice: False
depois do índice: True
Create indexes for the columns that appear in WHERE, in JOIN and in ORDER BY in the frequentqueries. Do not create one for each column: each index costs each INSERT.
What changes in PostgreSQL
| Subject | SQLite | PostgreSQL |
|---|---|---|
| Server | A file, without a process | A server, with users and network |
| Auto key | INTEGER PRIMARY KEY | INTEGER GENERATED ALWAYS AS IDENTITY |
| Parameter marker | ? | %s (in psycopg) |
| Types | Flexible (weak typing) | Strict, with JSONB, ARRAY, TIMESTAMPTZ |
| Concurrency | One write at a time | Many simultaneous writes (MVCC) |
| Return the created row | Limited | INSERT ... RETURNING |
With the driver psycopg (version 3), the connection is also a context manager: exiting the with without error confirms the transaction, and exiting with an exception undoes it. The blocks below run against a real PostgreSQL (the variable DATABASE_URL points to it):
import os
import psycopg
from psycopg.types.json import Jsonb
with psycopg.connect(os.environ["DATABASE_URL"]) as conexao:
conexao.execute("DROP TABLE IF EXISTS produtos")
conexao.execute("""
CREATE TABLE produtos (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
nome TEXT UNIQUE NOT NULL,
preco_centavos INTEGER NOT NULL,
atributos JSONB NOT NULL DEFAULT '{}'
)
""")
cursor = conexao.execute(
"INSERT INTO produtos (nome, preco_centavos, atributos) VALUES (%s, %s, %s) RETURNING id",
("caneta", 350, Jsonb({"cor": "azul"})),
)
print("id criado:", cursor.fetchone()[0])
id criado: 1
The RETURNING returns the key generated in the same operation, without a second query. The ON CONFLICT performs an upsert (inserts, or updates if the unique key already exists), and the operator ->> queries within the JSON:
import os
import psycopg
with psycopg.connect(os.environ["DATABASE_URL"]) as conexao:
conexao.execute(
"""INSERT INTO produtos (nome, preco_centavos) VALUES (%s, %s)
ON CONFLICT (nome) DO UPDATE SET preco_centavos = EXCLUDED.preco_centavos""",
("caneta", 400),
)
print(conexao.execute("SELECT nome, preco_centavos FROM produtos").fetchall())
print(conexao.execute("SELECT nome FROM produtos WHERE atributos->>'cor' = %s", ("azul",)).fetchall())
with psycopg.connect(os.environ["DATABASE_URL"]) as conexao:
try:
conexao.execute("INSERT INTO produtos (nome, preco_centavos) VALUES (%s, %s)", ("caneta", 1))
except psycopg.errors.UniqueViolation as erro:
print("violação de unicidade:", erro.diag.constraint_name)
conexao.rollback()
conexao.execute("DROP TABLE produtos")
[('caneta', 400)]
[('caneta',)]
violação de unicidade: produtos_nome_key
Two transactions, two worlds
In PostgreSQL, the default isolation (
READ COMMITTED) means that each command sees what has already been confirmed by other transactions. To avoid the race of "read the balance, calculate, write", useSELECT ... FOR UPDATE, which locks the row until the end of the transaction, or a constraint in the database. Data concurrency is the database's problem, not Python's.
Exercise 1
Total per customer, including those who did not purchase
Write total_por_cliente() that uses the above connection and returns a list of (nome, total) ordered by name, with zero for those who have no orders.