Stored Procedures, Triggers & Functions
Moving logic into the database: when, why, and how.
Database Logic: Power and Trade-offs
Should validation logic live in your application code or database constraints? Should calculations happen in Python or SQL functions? Should audit logging use application middleware or database triggers? These questions divide developers into camps: those who keep databases "dumb" (storage only) and those who leverage database logic (stored procedures, triggers, functions) for performance, consistency, and enforcement. This lesson covers stored procedures (reusable SQL code blocks), triggers (automatic actions on INSERT/UPDATE/DELETE), and user-defined functions (custom operations). You'll learn when database logic outperforms application logic (complex queries, atomic operations, data integrity), when it creates problems (testing, portability, version control), and the three positions teams typically settle into.
Try it locally (PostgreSQL 16)
You can reproduce the examples in this lesson on your own machine. Everything shown here was verified against this container.
# Plain PostgreSQL 16 is all this lesson needs. docker run -d --name pg-demo \ -e POSTGRES_PASSWORD=demo -e POSTGRES_USER=demo -e POSTGRES_DB=demo \ -p 5432:5432 postgres:16-alpine docker exec -it pg-demo psql -U demo -d demo -c "CREATE DATABASE ecommerce" docker exec -it pg-demo psql -U demo -d ecommerce # Then run the schema below before anything else. # Clean up: docker rm -f pg-demo
The tables every example on this page uses. The 100,000 products are for the set-based-versus-row-by-row benchmark near the end; everything else is small on purpose so you can check the arithmetic by hand.
-- Schema + seed. Every result on this page comes from exactly this data.
CREATE TABLE customers (
customer_id SERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL,
full_name VARCHAR(100)
);
CREATE TABLE users (
user_id SERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL,
username VARCHAR(50),
status VARCHAR(20) NOT NULL DEFAULT 'active'
);
CREATE TABLE products (
product_id SERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL,
price NUMERIC(10, 2) NOT NULL,
stock_quantity INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE orders (
order_id SERIAL PRIMARY KEY,
customer_id INTEGER REFERENCES customers(customer_id),
status VARCHAR(20) NOT NULL DEFAULT 'pending',
order_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
total_amount NUMERIC(10, 2) NOT NULL DEFAULT 0,
item_count INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE order_items (
order_item_id SERIAL PRIMARY KEY,
order_id INTEGER NOT NULL REFERENCES orders(order_id) ON DELETE CASCADE,
product_id INTEGER NOT NULL REFERENCES products(product_id),
quantity INTEGER NOT NULL,
unit_price NUMERIC(10, 2) NOT NULL
);
-- Seed: deterministic, no random().
INSERT INTO customers (email, full_name) VALUES
('ada@example.com', 'Ada Lovelace'),
('grace@example.com', 'Grace Hopper'),
('alan@example.com', 'Alan Turing');
INSERT INTO products (name, price, stock_quantity)
SELECT 'Product ' || g, (g * 13 % 500) + 9.99, 1000
FROM generate_series(1, 100000) g;
-- Customer 1: three completed orders and two pending ones.
INSERT INTO orders (customer_id, status, order_date, total_amount) VALUES
(1, 'completed', DATE '2026-05-04', 512.30),
(1, 'completed', DATE '2026-06-11', 899.00),
(1, 'completed', DATE '2026-07-02', 715.42),
(1, 'pending', DATE '2026-07-20', 0),
(1, 'pending', DATE '2026-07-25', 0),
(2, 'completed', DATE '2026-06-30', 1250.00),
(3, 'completed', DATE '2026-07-15', 340.00),
(2, 'pending', DATE '2026-07-26', 0);
-- Items for order 1 so calculate_order_total() has something to add up.
INSERT INTO order_items (order_id, product_id, quantity, unit_price) VALUES
(1, 1, 2, 100.00),
(1, 2, 1, 50.00),
(1, 3, 4, 25.00);
-- Items for the two pending orders.
INSERT INTO order_items (order_id, product_id, quantity, unit_price) VALUES
(4, 5, 3, 75.00),
(5, 7, 1, 199.99),
(8, 9, 5, 60.00);
ANALYZE;Database Logic vs Application Logic: The Trade-offs
The fundamental question: where should your logic live? Understanding the trade-offs helps you make informed architectural decisions.
Application Logic (Python/Java/Node)
Advantages:
- Easy to test (unit tests, mocks)
- Version control friendly (Git)
- Language flexibility (use best tool)
- Scales horizontally (add app servers)
- Database-agnostic (switch DB easily)
Disadvantages:
- Network round-trips (slower)
- Can be bypassed (direct DB access)
- Data transferred to app (memory)
- Consistency harder across apps
Database Logic (SQL/PL/pgSQL)
Advantages:
- Fast (no network, processes data in-place)
- Enforced universally (can't bypass)
- Atomic operations (no race conditions)
- Direct data access (no serialization)
- Set-based operations (bulk processing)
Disadvantages:
- Harder to test (DB required)
- Version control awkward (migrations)
- Vendor lock-in (PostgreSQL-specific)
- Scales vertically only (DB bottleneck)
- Debugging harder (limited tooling)
The Same Task, Both Ways
The lists above are easy to nod along to and hard to feel. Here is one concrete task, calculating an order total, drawn both ways. Count the arrows that cross between the two columns: those are the network round-trips, and they are the whole argument.
Application Logic: Four Crossings
Figure 1: Every row travels to the application and the answer travels back. The three middle steps are pure Python and touch no database at all, but they are sandwiched between two round-trips.
Database Logic: One Crossing
Figure 2: The same arithmetic, run where the rows already live. One request, one answer, and the intermediate rows never leave the server.
How to choose
Let the volume of data decide. When the logic reads far more rows than it returns, the round-trips dominate and the work belongs in the database. When it reads little and changes often, the testability and version control of application code are worth more than the milliseconds. The trap is treating this as a style preference: it is a measurement, and the answer differs per query rather than per codebase.
User-Defined Functions (UDFs)
User-defined functions encapsulate reusable logic in the database. They can return single values (scalar functions) or tables (table-valued functions).
Scalar Functions (Return Single Value)
-- Create function to calculate order total with tax
CREATE OR REPLACE FUNCTION calculate_order_total(
order_id_param INTEGER,
tax_rate DECIMAL DEFAULT 0.08
)
RETURNS DECIMAL(10, 2)
LANGUAGE plpgsql
AS $$
DECLARE
subtotal DECIMAL(10, 2);
total DECIMAL(10, 2);
BEGIN
-- Calculate subtotal from order items
SELECT COALESCE(SUM(quantity * unit_price), 0)
INTO subtotal
FROM order_items
WHERE order_id = order_id_param;
-- Apply tax. Assigning into a variable DECLAREd as DECIMAL(10, 2) is
-- what rounds the result to cents. The DECIMAL(10, 2) in the RETURNS
-- clause does nothing: PostgreSQL discards precision and scale on
-- function return types, so a function that returned the expression
-- directly would hand back the full unrounded value.
total := subtotal * (1 + tax_rate);
RETURN total;
END;
$$;
-- Usage in query
SELECT
order_id,
customer_id,
calculate_order_total(order_id) as total,
calculate_order_total(order_id, 0.10) as total_with_10pct_tax
FROM orders
WHERE order_date >= CURRENT_DATE - INTERVAL '7 days';Where Else a Function Can Go, and What It Costs
The query above calls the function in the SELECT list, which is the safe case. A function is an ordinary expression, so it is equally legal in aWHERE clause or a JOIN condition. Both work, and both are quietly expensive in a way the first one is not.
-- A function is an expression, so it is legal anywhere an expression is: -- in the SELECT list (above), in WHERE, and in a JOIN condition. SELECT order_id, calculate_order_total(order_id) AS total FROM orders WHERE calculate_order_total(order_id) > 100 ORDER BY order_id; SELECT o.order_id, c.full_name FROM orders o JOIN customers c ON c.customer_id = o.customer_id AND calculate_order_total(o.order_id) > 100 ORDER BY o.order_id; -- Legal is not the same as free. Ask what it costs: EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF) SELECT order_id FROM orders WHERE calculate_order_total(order_id) > 100; -- And the fix you will reach for does not work: CREATE INDEX orders_total_idx ON orders (calculate_order_total(order_id));
Expected Output:
order_id | total
----------+--------
1 | 378.00
4 | 243.00
5 | 215.99
8 | 324.00
(4 rows)
order_id | full_name
----------+--------------
1 | Ada Lovelace
4 | Ada Lovelace
5 | Ada Lovelace
8 | Grace Hopper
(4 rows)
QUERY PLAN
--------------------------------------------------------------------
Seq Scan on orders (actual rows=4 loops=1)
Filter: (calculate_order_total(order_id, 0.08) > '100'::numeric)
Rows Removed by Filter: 4
ERROR: functions in index expression must be marked IMMUTABLERead the plan, not the result
In the SELECT list the function runs once per returned row. In theWHERE clause it runs once per scanned row, to decide which rows are returned at all: the plan shows aSeq Scan with 4 more rows removed by the filter, so it ran 8 times to produce 4. Each of those calls runs its ownSELECT ... FROM order_items. At eight orders that is invisible; at eight million it is the whole query.
The instinct is to index the expression, and PostgreSQL refuses: index expressions must be IMMUTABLE, and this function isVOLATILE (the default when you declare no volatility). Marking it STABLE does not help either, the same error appears, andIMMUTABLE would be a lie because the function reads a table whose contents change. That is not a gap in PostgreSQL: a value derived from other rows cannot be indexed as though it were fixed. If you need to filter on the total, store it, in a column maintained by a trigger or in a materialized view, and index that.
Using Functions from Python
import psycopg
conn = psycopg.connect("host=localhost dbname=ecommerce user=demo password=demo")
cursor = conn.cursor()
# Call function directly
order_id = 1
cursor.execute("SELECT calculate_order_total(%s)", (order_id,))
total = cursor.fetchone()[0]
print(f"Order {order_id} total: ${total:.2f}")
# Use function in query
cursor.execute("""
SELECT
o.order_id,
o.customer_id,
calculate_order_total(o.order_id) as total_amount
FROM orders o
WHERE o.status = 'pending'
ORDER BY total_amount DESC
LIMIT 10
""")
print("\nTop 10 pending orders by value:")
for order_id, customer_id, total in cursor.fetchall():
print(f" Order {order_id} (Customer {customer_id}): ${total:.2f}")
cursor.close()
conn.close()Expected Output:
Order 1 total: $378.00 Top 10 pending orders by value: Order 8 (Customer 2): $324.00 Order 4 (Customer 1): $243.00 Order 5 (Customer 1): $215.99 378.00 is order 1's items (2x100.00 + 1x50.00 + 4x25.00 = 350.00) plus the 8% default tax rate. Check it by hand against the seed data above; that is what the small numbers are for. The function was called once per row of the outer query, not once for the query. Add a RAISE NOTICE inside it and you see exactly three notices for three pending orders, each one a fresh scan of order_items. Fine at this size, a problem at a million rows, and the standard objection to putting calculations in per-row functions. Note also that ORDER BY works on total_amount, the function's result, even though no such column exists. EXPLAIN shows the sort key as the function call itself: Sort Key: (calculate_order_total(order_id, 0.08)) DESC so the value is computed once per row on the way out of the scan and then sorted, rather than being recomputed during the sort.
Table-Valued Functions (Return Rows)
Everything so far returned a single value. A function declaredRETURNS TABLE (...) returns a whole result set instead, and that changes where it can be used: not in the SELECT list, but in theFROM clause, in the position a table would occupy. Think of it as a view that takes arguments, which is the thing a plain view cannot do.
The example below packages four per-customer aggregates behind one name, then joins that function against customers so each row gets its own statistics.
-- Function that returns table of customer purchase statistics
CREATE OR REPLACE FUNCTION get_customer_stats(
customer_id_param INTEGER
)
RETURNS TABLE (
total_orders INTEGER,
total_spent DECIMAL(10, 2),
avg_order_value DECIMAL(10, 2),
last_order_date DATE
)
LANGUAGE plpgsql
AS $$
BEGIN
RETURN QUERY
SELECT
COUNT(*)::INTEGER as total_orders,
COALESCE(SUM(total_amount), 0) as total_spent,
COALESCE(AVG(total_amount), 0) as avg_order_value,
MAX(order_date)::DATE as last_order_date
FROM orders
WHERE customer_id = customer_id_param
AND status = 'completed';
END;
$$;
-- Use like a regular table
SELECT * FROM get_customer_stats(1);
-- CAREFUL: the DECIMAL(10, 2) in the RETURNS TABLE list above is NOT
-- enforced. PostgreSQL uses those declarations for column names and
-- base types only; precision and scale are discarded. AVG here comes
-- back as 708.9066666666666667, not 708.91. Round explicitly inside
-- the function if the scale matters:
-- ROUND(COALESCE(AVG(total_amount), 0), 2)
-- Join with other tables
SELECT
c.customer_id,
c.email,
s.total_orders,
s.total_spent
FROM customers c
CROSS JOIN LATERAL get_customer_stats(c.customer_id) s
WHERE s.total_spent > 1000
ORDER BY s.total_spent DESC;Expected Output:
┌──────────────┬─────────────┬──────────────────────┬─────────────────┐ │ total_orders │ total_spent │ avg_order_value │ last_order_date │ ├──────────────┼─────────────┼──────────────────────┼─────────────────┤ │ 3 │ 2126.72 │ 708.9066666666666667 │ 2026-07-02 │ └──────────────┴─────────────┴──────────────────────┴─────────────────┘ (1 row) ┌─────────────┬───────────────────┬──────────────┬─────────────┐ │ customer_id │ email │ total_orders │ total_spent │ ├─────────────┼───────────────────┼──────────────┼─────────────┤ │ 1 │ ada@example.com │ 3 │ 2126.72 │ │ 2 │ grace@example.com │ 1 │ 1250.00 │ └─────────────┴───────────────────┴──────────────┴─────────────┘ (2 rows) Note avg_order_value: 708.9066666666666667, not 708.91. That is the discarded DECIMAL(10, 2) from the RETURNS TABLE list, exactly as the comment above warns.
About that LATERAL
LATERAL is what lets a FROM item reference a column from an earlier one, which is exactly whatget_customer_stats(c.customer_id) needs. Worth knowing though: for a function call the keyword is optional. WritingFROM customers c, get_customer_stats(c.customer_id) sproduces the identical result, because a function in FROM is implicitly lateral. Spelling it out is a readability choice, not a requirement. For a subquery it is mandatory.
The cost is the same shape as the previous section:EXPLAIN ANALYZE reportsFunction Scan on get_customer_stats (actual rows=1 loops=3), one execution per customer row. That is a nested loop by construction, so the function body runs once per left-hand row no matter how selective the outerWHERE looks.
Calling Table-Valued Functions from Python
import psycopg
conn = psycopg.connect("host=localhost dbname=ecommerce user=demo password=demo")
cursor = conn.cursor()
# Get stats for specific customer
customer_id = 1
cursor.execute("SELECT * FROM get_customer_stats(%s)", (customer_id,))
stats = cursor.fetchone()
if stats:
total_orders, total_spent, avg_order, last_order = stats
print(f"Customer {customer_id} Statistics:")
print(f" Total Orders: {total_orders}")
print(f" Total Spent: ${total_spent:.2f}")
print(f" Average Order: ${avg_order:.2f}")
print(f" Last Order: {last_order}")
# Get all high-value customers
cursor.execute("""
SELECT
c.customer_id,
c.email,
s.total_orders,
s.total_spent
FROM customers c
CROSS JOIN LATERAL get_customer_stats(c.customer_id) s
WHERE s.total_spent > 1000
ORDER BY s.total_spent DESC
LIMIT 10
""")
print("\nTop 10 High-Value Customers:")
for cust_id, email, orders, spent in cursor.fetchall():
print(f" {email}: {orders} orders, ${spent:.2f} spent")
cursor.close()
conn.close()Expected Output:
Customer 1 Statistics: Total Orders: 3 Total Spent: $2126.72 Average Order: $708.91 Last Order: 2026-07-02 Top 10 High-Value Customers: ada@example.com: 3 orders, $2126.72 spent grace@example.com: 1 orders, $1250.00 spent The average prints as 708.91 here only because the f-string asked for :.2f. The value psycopg handed back was 708.9066666666666667, since the DECIMAL(10, 2) in the function's RETURNS TABLE clause is decorative: PostgreSQL keeps the column names and base types from that clause and throws away precision and scale. Format at the edge, or round inside the function; do not assume the declared scale did it for you. The second query is the reason CROSS JOIN LATERAL exists. A plain join cannot pass c.customer_id into a set-returning function; LATERAL can, because it evaluates the right-hand side once per row of the left. The cost is the same one as before: the function body runs once per customer, so this is a per-row loop wearing a join's clothes.
Stored Procedures
Stored procedures are similar to functions but designed for complex operations that modify data, handle transactions, and perform multiple steps. Unlike functions, they don't return values directly but can have OUT parameters.
Creating a Stored Procedure
-- Procedure to process an order (multi-step transaction)
CREATE OR REPLACE PROCEDURE process_order(
p_customer_id INTEGER,
p_product_ids INTEGER[],
p_quantities INTEGER[],
OUT p_order_id INTEGER,
OUT p_total_amount DECIMAL
)
LANGUAGE plpgsql
AS $$
DECLARE
v_product_id INTEGER;
v_quantity INTEGER;
v_price DECIMAL;
v_available_stock INTEGER;
BEGIN
-- NOTE: this BEGIN opens a PL/pgSQL block, not a transaction. The
-- procedure runs inside whatever transaction the caller already has,
-- so an exception anywhere below undoes every step above it.
-- Create order
INSERT INTO orders (customer_id, status, order_date)
VALUES (p_customer_id, 'pending', CURRENT_TIMESTAMP)
RETURNING order_id INTO p_order_id;
-- Initialize total
p_total_amount := 0;
-- Process each item
FOR i IN 1..array_length(p_product_ids, 1) LOOP
v_product_id := p_product_ids[i];
v_quantity := p_quantities[i];
-- Get product price and check stock
SELECT price, stock_quantity
INTO v_price, v_available_stock
FROM products
WHERE product_id = v_product_id
FOR UPDATE; -- Lock row
-- Validate stock
IF v_available_stock < v_quantity THEN
RAISE EXCEPTION 'Insufficient stock for product %', v_product_id;
END IF;
-- Add order item
INSERT INTO order_items (order_id, product_id, quantity, unit_price)
VALUES (p_order_id, v_product_id, v_quantity, v_price);
-- Update inventory
UPDATE products
SET stock_quantity = stock_quantity - v_quantity
WHERE product_id = v_product_id;
-- Add to total
p_total_amount := p_total_amount + (v_price * v_quantity);
END LOOP;
-- Update order total
UPDATE orders
SET total_amount = p_total_amount
WHERE order_id = p_order_id;
-- Returning normally does NOT commit. The caller decides: psql in
-- autocommit commits when CALL returns, but inside an explicit
-- BEGIN ... ROLLBACK the whole order disappears again.
RAISE NOTICE 'Order % created successfully', p_order_id;
END;
$$;Calling Stored Procedures from Python
import psycopg
DSN = "host=localhost dbname=ecommerce user=demo password=demo"
def order_count(cur):
cur.execute("SELECT count(*) FROM orders")
return cur.fetchone()[0]
# --- Happy path -----------------------------------------------------------
with psycopg.connect(DSN) as conn, conn.cursor() as cur:
cur.execute("CALL process_order(%s, %s, %s, NULL, NULL)",
(1, [1, 2, 3], [2, 1, 3]))
# psycopg 3 hands the procedure's OUT parameters back as an ordinary
# result row, so there is no need to guess which order was just created
# by re-querying and sorting.
new_order_id, total = cur.fetchone()
conn.commit()
print("Order created successfully!")
print(f" Order ID: {new_order_id}")
print(f" Total: ${total:.2f}")
# --- Failure path: ask for more stock than exists -------------------------
with psycopg.connect(DSN) as conn, conn.cursor() as cur:
before = order_count(cur)
try:
cur.execute("CALL process_order(%s, %s, %s, NULL, NULL)",
(1, [1, 2], [1, 999999]))
except psycopg.errors.RaiseException as e:
conn.rollback()
print(f"\nOrder failed: {str(e).strip().splitlines()[0]}")
after = order_count(cur)
print(f"orders before failed call: {before}, after: {after}")Expected Output:
Order created successfully!
Order ID: 9
Total: $228.94
Order failed: Insufficient stock for product 2
orders before failed call: 9, after: 9
Two things worth pulling out.
First, CALL returns the OUT parameters as an ordinary result row, so
cur.fetchone() gives you (p_order_id, p_total_amount) directly. Do not
re-query "the newest order for this customer" to find out what you just
created: with several orders sharing a timestamp that ORDER BY is a
coin flip.
Second, the failure path is the reason this is a procedure at all. The
exception fired on the SECOND item, after the orders row had been
inserted and product 1's stock already decremented. Everything rolled
back together: the order count is unchanged, and the stock table shows
only the successful order's deductions.
product_id | stock_quantity
-----------+----------------
1 | 998 <- 1000 - 2, the successful order only
2 | 999
3 | 997
Do the same work as separate statements from the application and a crash
between them leaves a half-built order and inventory that no longer
matches what was sold.
Note also FOR UPDATE on the products row. Two concurrent calls for the
last item in stock serialise on that lock, so they cannot both read 1 and
both decide there is enough.Alternative: The Same Job as a Function
The same order-creation logic can be written as a function instead, and it is worth seeing side by side because the choice between the two is usually made for the wrong reason. Two things change here: the items arrive as a single JSONB argument rather than as parallel arrays, and the result comes back as a returned row instead ofOUT parameters.
-- Function version that returns order details
CREATE OR REPLACE FUNCTION create_order(
p_customer_id INTEGER,
p_items JSONB -- [{"product_id": 101, "quantity": 2}, ...]
)
RETURNS TABLE (
order_id INTEGER,
total_amount DECIMAL,
items_count INTEGER
)
LANGUAGE plpgsql
AS $$
DECLARE
v_order_id INTEGER;
v_total DECIMAL := 0;
v_item JSONB;
v_price DECIMAL;
BEGIN
-- Create order
INSERT INTO orders (customer_id, status)
VALUES (p_customer_id, 'pending')
RETURNING orders.order_id INTO v_order_id;
-- Process each item
FOR v_item IN SELECT * FROM jsonb_array_elements(p_items)
LOOP
SELECT price INTO v_price
FROM products
WHERE product_id = (v_item->>'product_id')::INTEGER;
INSERT INTO order_items (order_id, product_id, quantity, unit_price)
VALUES (
v_order_id,
(v_item->>'product_id')::INTEGER,
(v_item->>'quantity')::INTEGER,
v_price
);
v_total := v_total + (v_price * (v_item->>'quantity')::INTEGER);
END LOOP;
-- Update order total
UPDATE orders SET total_amount = v_total WHERE orders.order_id = v_order_id;
-- Return result
RETURN QUERY
SELECT v_order_id, v_total, jsonb_array_length(p_items);
END;
$$;Because it is a function, it is called with SELECT rather thanCALL, which also means it can be dropped into a larger query wherever a table is allowed:
import json
import psycopg
conn = psycopg.connect("host=localhost dbname=ecommerce user=demo password=demo")
cursor = conn.cursor()
items = [
{"product_id": 101, "quantity": 2},
{"product_id": 102, "quantity": 1}
]
# The function returns one row, so SELECT * FROM it and fetch once.
cursor.execute("SELECT * FROM create_order(%s, %s::jsonb)",
(1, json.dumps(items)))
order_id, total, count = cursor.fetchone()
print(f"Created order {order_id}: ${total:.2f} ({count} items)")
conn.commit()
cursor.close()
conn.close()Expected Output:
Created order 11: $981.97 (2 items) 981.97 is 2 x 322.99 plus 1 x 335.99, the seeded prices of products 101 and 102. The function looked those up itself: the caller sent quantities and product ids, never a price, which is the point. A price passed in from the application is a price the application can get wrong. Your order id will differ if you have run the earlier examples a different number of times; the sequence keeps counting.
Function or procedure? Choose on transaction control
A function composes: it can appear in a SELECT, in aJOIN, and it can RETURN QUERY a result set, which is how this one hands back order id, total, and item count in a single row. What it cannot do is end a transaction. Put a COMMIT inside one and PostgreSQL refuses at runtime:
ERROR: invalid transaction terminationA procedure can COMMIT and ROLLBACK mid-body, which is what lets it chunk a long job into batches that each land independently. It pays for that by being callable only with CALL, never as part of a query. So the question is not which keyword you prefer: it is whether the routine needs to be composable or needs to steer its own transactions. It cannot be both.
Triggers: Automatic Database Actions
Triggers automatically execute functions in response to INSERT, UPDATE, or DELETE operations. They're perfect for audit trails, data validation, denormalization, and enforcing complex business rules.
Audit Trail Trigger
-- Create audit log table
CREATE TABLE user_audit_log (
audit_id SERIAL PRIMARY KEY,
user_id INTEGER,
action VARCHAR(10), -- 'INSERT', 'UPDATE', 'DELETE'
old_data JSONB,
new_data JSONB,
changed_by VARCHAR(100),
changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Trigger function for auditing
CREATE OR REPLACE FUNCTION audit_user_changes()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
INSERT INTO user_audit_log (user_id, action, new_data, changed_by)
VALUES (NEW.user_id, 'INSERT', row_to_json(NEW)::jsonb, current_user);
RETURN NEW;
ELSIF TG_OP = 'UPDATE' THEN
INSERT INTO user_audit_log (user_id, action, old_data, new_data, changed_by)
VALUES (
NEW.user_id,
'UPDATE',
row_to_json(OLD)::jsonb,
row_to_json(NEW)::jsonb,
current_user
);
RETURN NEW;
ELSIF TG_OP = 'DELETE' THEN
INSERT INTO user_audit_log (user_id, action, old_data, changed_by)
VALUES (OLD.user_id, 'DELETE', row_to_json(OLD)::jsonb, current_user);
RETURN OLD;
END IF;
END;
$$;
-- Attach trigger to users table
CREATE TRIGGER users_audit_trigger
AFTER INSERT OR UPDATE OR DELETE ON users
FOR EACH ROW
EXECUTE FUNCTION audit_user_changes();Testing the Audit Trigger
import psycopg
conn = psycopg.connect("host=localhost dbname=ecommerce user=demo password=demo")
cursor = conn.cursor()
# Insert a user (trigger fires automatically)
cursor.execute("""
INSERT INTO users (email, username, status)
VALUES ('alice@example.com', 'alice', 'active')
RETURNING user_id
""")
user_id = cursor.fetchone()[0]
conn.commit()
print(f"Created user {user_id}")
# Update the user (trigger fires again)
cursor.execute("""
UPDATE users
SET status = 'inactive'
WHERE user_id = %s
""", (user_id,))
conn.commit()
print(f"Updated user {user_id}")
# Check audit log
cursor.execute("""
SELECT
audit_id,
action,
old_data->>'status' as old_status,
new_data->>'status' as new_status,
changed_at
FROM user_audit_log
WHERE user_id = %s
-- changed_at can tie when several changes share a transaction,
-- so break the tie on the serial audit_id.
ORDER BY changed_at, audit_id
""", (user_id,))
print(f"\nAudit trail for user {user_id}:")
for audit_id, action, old_status, new_status, changed_at in cursor.fetchall():
if action == 'INSERT':
print(f" [{changed_at}] INSERT - New status: {new_status}")
elif action == 'UPDATE':
print(f" [{changed_at}] UPDATE - Status: {old_status} → {new_status}")
cursor.close()
conn.close()Expected Output:
Created user 1 Updated user 1 Audit trail for user 1: [2026-07-29 18:20:22.593965] INSERT - New status: active [2026-07-29 18:20:22.601095] UPDATE - Status: active → inactive The application code never mentions user_audit_log. Both rows appeared because the trigger fired, which is the whole appeal: an audit trail that cannot be forgotten, bypassed by a different service, or skipped by someone running an ad-hoc UPDATE in psql. The two timestamps are 7 ms apart and came from CURRENT_TIMESTAMP, which in PostgreSQL is the start of the enclosing TRANSACTION, not the moment of the statement. Here each statement had its own transaction, so the values differ. Batch several changes into one transaction and every audit row shares a timestamp, which is why the query above breaks ties on audit_id. Use clock_timestamp() if you need the rows themselves to be distinguishable.
Validation Trigger (BEFORE INSERT/UPDATE)
The audit trigger above ran AFTER, once the row was already written. ABEFORE trigger runs while the row is still just a proposal held in NEW, and that timing gives it three powers the audit trigger does not have:
- Rewrite the row - assigning to
NEWchanges what actually gets stored, which is howNEW.email := LOWER(TRIM(NEW.email))below normalises every write no matter which client made it - Reject the row loudly -
RAISE EXCEPTIONaborts the statement and the surrounding transaction with it - Drop the row silently -
RETURN NULLcancels just that row with no error at all. Use it deliberately and rarely: the statement reports success while doing nothing, which is a genuinely hard bug to find
-- Trigger function to validate and normalize email
CREATE OR REPLACE FUNCTION validate_user_email()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
-- Normalize email to lowercase
NEW.email := LOWER(TRIM(NEW.email));
-- Validate email format
IF NEW.email !~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+.[A-Za-z]{2,}$' THEN
RAISE EXCEPTION 'Invalid email format: %', NEW.email;
END IF;
-- Prevent blocked domains
IF NEW.email LIKE '%@spam.com' OR NEW.email LIKE '%@temporary.com' THEN
RAISE EXCEPTION 'Email domain not allowed: %', NEW.email;
END IF;
RETURN NEW;
END;
$$;
-- Attach BEFORE trigger (runs before INSERT/UPDATE)
CREATE TRIGGER validate_email_trigger
BEFORE INSERT OR UPDATE OF email ON users
FOR EACH ROW
EXECUTE FUNCTION validate_user_email();Two details in thatCREATE TRIGGER
UPDATE OF email narrows when the trigger fires: only whenemail appears in the SET list. AnUPDATE users SET status = 'inactive' does not run the validation at all, which is what you want, since revalidating an untouched column on every write is wasted work.
FOR EACH ROW is what makes NEW exist. The alternative,FOR EACH STATEMENT, fires once per statement regardless of how many rows it touched and has no NEW to inspect or rewrite, so it is useless for validation.
Testing Validation Trigger
import psycopg
conn = psycopg.connect("host=localhost dbname=ecommerce user=demo password=demo")
cursor = conn.cursor()
# Valid email (will be normalized to lowercase)
try:
cursor.execute("""
INSERT INTO users (email, username)
VALUES ('Alice@Example.COM', 'alice')
RETURNING email
""")
normalized_email = cursor.fetchone()[0]
conn.commit()
print(f"✓ Email normalized: {normalized_email}")
except psycopg.Error as e:
print(f"✗ Error: {e}")
conn.rollback()
# Invalid email format (will fail)
try:
cursor.execute("""
INSERT INTO users (email, username)
VALUES ('not-an-email', 'bob')
""")
conn.commit()
except psycopg.Error as e:
print(f"✗ Validation failed (expected): {e.diag.message_primary}")
conn.rollback()
# Blocked domain (will fail)
try:
cursor.execute("""
INSERT INTO users (email, username)
VALUES ('user@spam.com', 'charlie')
""")
conn.commit()
except psycopg.Error as e:
print(f"✗ Domain blocked (expected): {e.diag.message_primary}")
conn.rollback()
cursor.close()
conn.close()Expected Output:
✓ Email normalized: alice@example.com ✗ Validation failed (expected): Invalid email format: not-an-email ✗ Domain blocked (expected): Email domain not allowed: user@spam.com 'Alice@Example.COM' was stored lowercased. A BEFORE trigger can rewrite NEW before the row is written, which an AFTER trigger or a CHECK constraint cannot: both see the row too late to change it. That is the one thing BEFORE is for. Both rejections surfaced as psycopg.Error, and e.diag.message_primary gives just the RAISE text without the PL/pgSQL context lines. In psycopg 3 there is no .pgerror attribute; reach for .diag instead. Worth knowing where this stops: normalising in a trigger protects every writer, but it also means the value you read back is not the value you sent. Code that compares its input to the stored row will see a mismatch that is not a bug.
Denormalization Trigger (Maintain Computed Columns)
This one solves the problem left open earlier in the lesson: a total you want to filter and sort by cannot be a function call, because a derived value cannot be indexed. So store it in a real column and let a trigger keep it honest. The trigger fires onorder_items but writes to orders, which is the shape of every denormalisation trigger: watch the detail table, maintain the summary.
Two new pieces of trigger vocabulary appear below.TG_OP holds the operation that fired the trigger, and it is needed here because DELETE has no NEW row whileINSERT has no OLD one: the deleted row is the only place to find out which order to recalculate.
-- Keep order.item_count and order.total_amount in sync with order_items
CREATE OR REPLACE FUNCTION update_order_totals()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
DECLARE
v_order_id INTEGER;
BEGIN
-- Determine which order to update
IF TG_OP = 'DELETE' THEN
v_order_id := OLD.order_id;
ELSE
v_order_id := NEW.order_id;
END IF;
-- Recalculate order totals
UPDATE orders
SET
item_count = (
SELECT COUNT(*)
FROM order_items
WHERE order_id = v_order_id
),
total_amount = (
SELECT COALESCE(SUM(quantity * unit_price), 0)
FROM order_items
WHERE order_id = v_order_id
)
WHERE order_id = v_order_id;
IF TG_OP = 'DELETE' THEN
RETURN OLD;
ELSE
RETURN NEW;
END IF;
END;
$$;
-- Trigger fires when order_items change
CREATE TRIGGER maintain_order_totals
AFTER INSERT OR UPDATE OR DELETE ON order_items
FOR EACH ROW
EXECUTE FUNCTION update_order_totals();The RETURN at the end is ceremony
In a BEFORE trigger the returned row is the row that gets written, which is why the previous example could rewrite or cancel it. In anAFTER trigger like this one the write has already happened and the return value is discarded entirely: an AFTER trigger that does nothing but RETURN NULL still leaves the row in place (INSERT 0 1). Returning OLD or NEW here is convention, not control. Worth knowing so you do not go hunting for a bug when a colleague's AFTER trigger returns something odd.
Testing Denormalization Trigger
import psycopg
conn = psycopg.connect("host=localhost dbname=ecommerce user=demo password=demo")
cursor = conn.cursor()
# Create an order
cursor.execute("""
INSERT INTO orders (customer_id, item_count, total_amount)
VALUES (1, 0, 0)
RETURNING order_id
""")
order_id = cursor.fetchone()[0]
conn.commit()
print(f"Created order {order_id}")
# Add items (trigger updates order totals automatically)
items = [
(order_id, 101, 2, 49.99), # 2 items at $49.99
(order_id, 102, 1, 99.99), # 1 item at $99.99
]
for order, product, qty, price in items:
cursor.execute("""
INSERT INTO order_items (order_id, product_id, quantity, unit_price)
VALUES (%s, %s, %s, %s)
""", (order, product, qty, price))
conn.commit()
# Check order totals (updated by trigger)
cursor.execute("""
SELECT item_count, total_amount
FROM orders
WHERE order_id = %s
""", (order_id,))
item_count, total = cursor.fetchone()
print(f"\nOrder {order_id} totals (auto-updated by trigger):")
print(f" Item count: {item_count}")
print(f" Total amount: ${total:.2f}")
# Delete an item (trigger updates totals again)
cursor.execute("""
DELETE FROM order_items
WHERE order_id = %s AND product_id = 102
""", (order_id,))
conn.commit()
cursor.execute("""
SELECT item_count, total_amount
FROM orders
WHERE order_id = %s
""", (order_id,))
item_count, total = cursor.fetchone()
print(f"\nAfter deleting one item:")
print(f" Item count: {item_count}")
print(f" Total amount: ${total:.2f}")
cursor.close()
conn.close()Expected Output:
Created order 12 Order 12 totals (auto-updated by trigger): Item count: 2 Total amount: $199.97 After deleting one item: Item count: 1 Total amount: $99.98 The application inserted into order_items and never touched orders, yet orders stayed in sync, on the DELETE as well as the INSERTs. That is the argument for maintaining a denormalised total in a trigger rather than in application code: there is no code path that can forget. Watch what item_count actually counts. Two rows were inserted with quantities 2 and 1, and it reports 2, not 3. The trigger counts order_items ROWS, not units sold. Both are defensible; the name is not. Call it line_item_count if that is what it means, or the first person to build a "units shipped" report off this column will be wrong and never know. If you followed the page in order, the id is 12 rather than 10: the failed process_order call earlier still consumed a sequence value. Sequences do not roll back, by design, because rolling them back would serialise every insert. Gaps in a SERIAL column are normal and are not evidence of deleted rows.
Performance Implications
Database logic can be much faster or much slower than application logic, depending on the operation. Understanding when each approach wins is critical for performance.
When Database Logic is Faster
- Set-based operations: Processing millions of rows with UPDATE/DELETE
- Complex joins: Database optimizer handles efficiently
- Aggregations: SUM, COUNT, AVG on large datasets
- No network overhead: Data stays in database
- Parallel execution: Database uses multiple cores
When Database Logic is Slower
- Row-by-row loops: CURSOR loops are slower than set operations
- External API calls: Can't make HTTP requests from DB
- Complex logic: Procedural code often slower than Python
- Heavy triggers: Slow down every INSERT/UPDATE
- Limited CPU: Database can't scale horizontally
Performance Comparison Example
import time
from decimal import Decimal
import psycopg
conn = psycopg.connect("host=localhost dbname=ecommerce user=demo password=demo")
cursor = conn.cursor()
# Task: Update prices for 100,000 products with 10% increase
# APPROACH 1: Application Logic (SLOW)
start = time.time()
cursor.execute("SELECT product_id, price FROM products")
products = cursor.fetchall() # Transfer 100K rows to Python
for product_id, price in products:
# A NUMERIC column arrives as decimal.Decimal, and Decimal refuses to
# mix with float: price * 1.10 raises TypeError. Multiply by another
# Decimal, never by a float literal, or you silently reintroduce the
# binary-floating-point error that NUMERIC exists to avoid.
new_price = price * Decimal("1.10")
cursor.execute("UPDATE products SET price = %s WHERE product_id = %s",
(new_price, product_id))
conn.commit()
app_time = time.time() - start
print(f"Application logic: {app_time:.2f} seconds")
# APPROACH 2: Database Logic (FAST)
start = time.time()
cursor.execute("UPDATE products SET price = price * 1.10")
conn.commit()
db_time = time.time() - start
print(f"Database logic: {db_time:.2f} seconds")
print(f"\nSpeedup: {app_time / db_time:.1f}x faster")
cursor.close()
conn.close()Expected Output:
Application logic: 14.90 seconds
Database logic: 0.38 seconds
Speedup: 39.6x faster
Measured on 100,000 rows, PostgreSQL 16, client and server on the same
machine; the absolute seconds will differ on yours, the shape will not.
The gap is almost entirely round trips: the first version does 100,001 of
them, the second does one. Put a network between client and server and the
first version gets dramatically worse while the second barely moves.
The first version also has a correctness trap the timing hides. Written the
obvious way, as price * 1.10, it does not run at all:
TypeError: unsupported operand type(s) for *: 'decimal.Decimal' and 'float'
psycopg maps NUMERIC to decimal.Decimal precisely so money does not go
through a float. "Fixing" the error by casting the price to float would
throw that guarantee away, which is why the loop multiplies by
Decimal("1.10") instead.Trigger Performance Impact
import statistics
import time
import psycopg
conn = psycopg.connect("host=localhost dbname=ecommerce user=demo password=demo")
cursor = conn.cursor()
N = 10_000
REPS = 7
def with_trigger(on):
cursor.execute("DROP TRIGGER IF EXISTS users_audit_trigger ON users")
if on:
cursor.execute("""
CREATE TRIGGER users_audit_trigger
AFTER INSERT ON users
FOR EACH ROW EXECUTE FUNCTION audit_user_changes()
""")
conn.commit()
def reset():
cursor.execute("TRUNCATE user_audit_log; DELETE FROM users")
conn.commit()
def time_median(fn):
times = []
for _ in range(REPS):
reset()
start = time.perf_counter()
fn()
conn.commit()
times.append((time.perf_counter() - start) * 1000)
return statistics.median(times)
def one_statement_per_row():
for i in range(N):
cursor.execute("INSERT INTO users (email, username) VALUES (%s, %s)",
(f'user{i}@test.com', f'user{i}'))
def one_statement_total():
cursor.execute("""
INSERT INTO users (email, username)
SELECT 'user' || g || '@test.com', 'user' || g
FROM generate_series(1, %s) g
""", (N,))
for label, work in (("10,000 round trips", one_statement_per_row),
("1 INSERT ... SELECT", one_statement_total)):
with_trigger(False)
off = time_median(work)
with_trigger(True)
on = time_median(work)
print(f"{label}")
print(f" without trigger: {off:8.1f} ms")
print(f" with trigger: {on:8.1f} ms")
print(f" overhead: {(on / off - 1) * 100:7.1f}% "
f"({(on - off) / N * 1000:.1f} us per row)")
cursor.close()
conn.close()Expected Output:
10,000 round trips without trigger: 1106.3 ms with trigger: 1292.9 ms overhead: 16.9% (18.7 us per row) 1 INSERT ... SELECT without trigger: 33.1 ms with trigger: 91.0 ms overhead: 174.7% (5.8 us per row) Same trigger, same 10,000 rows, same machine: 17% or 175% depending entirely on how the rows arrive. That is why you should distrust any single percentage quoted for trigger overhead, including one you measured yourself. The 17% version is the misleading one. Both of its numbers are dominated by 10,000 network round trips, so the trigger's real cost is hiding inside a second of waiting. Take the round trips away and the same trigger nearly triples the cost of the insert, because every row now also serialises itself to JSONB and writes a second row. The number to budget with is the per-row one, and note it is roughly stable across both shapes at about 6 us of actual server work (the 18.7 us figure is round-trip noise, and it swings between runs). Decide whether 6 us per row is acceptable at your write rate. At 1,000 inserts/sec it is 0.6% of a core; on a bulk load of 100 million rows it is ten minutes.
Strategic Use of Database Logic: Decision Framework
Use this framework to decide when to use database logic vs application logic:
| Use Case | Recommended Approach | Reason |
|---|---|---|
| Audit trails | Database (Triggers) | Can't be bypassed, captures all changes universally |
| Data validation | Both | App for UX, DB constraints for enforcement |
| Complex calculations | Database (Functions) | Faster, no network overhead, reusable in SQL |
| Business logic | Application | Easier to test, version, and modify |
| Bulk updates | Database (SQL) | Set-based operations are orders of magnitude faster |
| External API calls | Application | Database can't make HTTP requests |
| Denormalization | Database (Triggers) | Keeps derived data in sync automatically |
| Report generation | Database (Functions) | Process data where it lives, return only results |
| Machine learning | Application | Better libraries, GPU support, flexibility |
| Referential integrity | Database (Constraints) | Enforced at database level, can't be bypassed |
Three Common Positions on Database Logic
Teams tend to settle into one of three positions. None is universally right, and the reasoning matters more than the label: what you are really choosing is where an invariant is allowed to be violated.
1. Application-Only
Minimal database logic:
- No stored procedures or triggers
- Foreign key constraints for integrity
- Check constraints for simple validation
- All business logic in Ruby/Go
- Why: Easier to test, deploy, and scale horizontally
2. Database-Enforced Invariants
Strategic database logic:
- Triggers for audit logs (compliance)
- Functions for financial calculations
- Constraints for money invariants
- Business logic still in application
- Why: Financial correctness requires DB enforcement
3. Hybrid
Hybrid approach:
- Functions for recommendation scoring
- Procedures for ETL batch jobs
- No triggers (prefer event streams)
- Microservices for business logic
- Why: Use DB for what it does best, apps for everything else
Your Approach (Recommended)
Pragmatic balance:
- ✓ Triggers for audit trails
- ✓ Constraints for data integrity
- ✓ Functions for complex queries
- ✗ Business logic in database
- ✗ Heavy procedural code in DB
Key Takeaways
- Functions: Reusable logic that returns values (scalar or tables)
- Stored procedures: Multi-step operations with transaction control
- Triggers: Automatic actions on INSERT/UPDATE/DELETE
- BEFORE triggers: Validate and modify data before saving
- AFTER triggers: Audit trails, denormalization, notifications
- Performance: Set-based DB operations beat row-by-row loops
- Testing: Application logic easier to test than DB logic
- Balance: Use DB for integrity, app for flexibility