Skip to content

Python cookbook

Practical recipes for the plxpython3 dialect. Every recipe here was run on PostgreSQL; plx transpiles the body to plpgsql and the standard interpreter executes it. See the plxpython3 chapter for the full language reference.

Scalar function with branching

An if / elif / else chain selects a value. Locals are declared by annotating them with #:: type and assigned with =.

CREATE FUNCTION letter_grade(score int) RETURNS text LANGUAGE plxpython3 AS $$
result #:: text
if score >= 90:
    result = "A"
elif score >= 80:
    result = "B"
elif score >= 70:
    result = "C"
else:
    result = "F"
return result
$$;
SELECT letter_grade(95) AS a, letter_grade(83) AS b, letter_grade(72) AS c, letter_grade(50) AS f;
 a | b | c | f
---+---+---+---
 A | B | C | F
(1 row)

Accumulating loop with range

for i in range(a, b) counts from a to b - 1. Give the accumulator an explicit type so the arithmetic does not overflow int.

CREATE FUNCTION factorial(n int) RETURNS bigint LANGUAGE plxpython3 AS $$
result = 1 #:: bigint
for i in range(1, n + 1):
    result = result * i
return result
$$;
SELECT factorial(5) AS f5, factorial(10) AS f10;
 f5  |   f10
-----+---------
 120 | 3628800
(1 row)

Building a string in a loop

Use += on a text variable. plx lowers this to its string builder, so each append is amortized O(1) on PostgreSQL 18 instead of the O(n^2) copy that s = s || x would cost. The f"{i}" converts the integer to text before it is appended.

CREATE FUNCTION int_csv(n int) RETURNS text LANGUAGE plxpython3 AS $$
s = "" #:: text
for i in range(1, n + 1):
    if i > 1:
        s += ", "
    s += f"{i}"
return s
$$;
SELECT int_csv(5) AS csv;
      csv
---------------
 1, 2, 3, 4, 5
(1 row)

Looping over a query

for row in query(...) iterates the rows of a result set. Each row is a record; access columns with row.col. The f-string interpolates the argument g into the SQL text.

CREATE FUNCTION order_total(g int) RETURNS bigint LANGUAGE plxpython3 AS $$
total = 0 #:: bigint
for row in query(f"SELECT amount FROM orders WHERE grp = {g}"):
    total = total + row.amount
return total
$$;
CREATE TABLE orders(id int, grp int, amount int);
INSERT INTO orders VALUES (1,1,100),(2,1,250),(3,2,40),(4,1,10);
SELECT order_total(1) AS grp1, order_total(2) AS grp2;
 grp1 | grp2
------+------
  360 |   40
(1 row)

Set-returning function

A function returning SETOF emits rows with return_next(...). A bare return ends the function.

CREATE FUNCTION divisors(n int) RETURNS SETOF int LANGUAGE plxpython3 AS $$
for i in range(1, n + 1):
    if n % i == 0:
        return_next(i)
return
$$;
SELECT array_agg(d) AS divisors_of_12 FROM divisors(12) d;
 divisors_of_12
----------------
 {1,2,3,4,6,12}
(1 row)

Error handling with try / except

try / except maps to a plpgsql exception block. Name a specific condition with the PG:: spelling; PG::DivisionByZero catches division_by_zero. The e.message accessor reads SQLERRM.

CREATE FUNCTION safe_divide(a int, b int) RETURNS int LANGUAGE plxpython3 AS $$
try:
    return a / b
except PG::DivisionByZero as e:
    raise("notice", f"caught: {e.message}")
    return -1
$$;
SELECT safe_divide(7, 2) AS ok, safe_divide(1, 0) AS zero;
NOTICE:  caught: division by zero
 ok | zero
----+------
  3 |   -1
(1 row)

Trigger function

A function returning trigger can back a trigger. Assign to NEW fields and return NEW. Here a BEFORE INSERT trigger fills in a default label and normalizes the code to upper case.

CREATE FUNCTION stamp_widget() RETURNS trigger LANGUAGE plxpython3 AS $$
if NEW.label is None:
    NEW.label = f"row {NEW.id}"
NEW.code = upper(NEW.code)
return NEW
$$;
CREATE TABLE widget(id int primary key, label text, code text);
CREATE TRIGGER widget_stamp BEFORE INSERT ON widget
    FOR EACH ROW EXECUTE FUNCTION stamp_widget();
INSERT INTO widget(id, label, code) VALUES (1, NULL, 'ab'), (2, 'named', 'cd');
SELECT id, label, code FROM widget ORDER BY id;
 id | label | code
----+-------+------
  1 | row 1 | AB
  2 | named | CD
(2 rows)

Dynamic SQL with bind parameters

Interpolate identifiers you control (a table name) with an f-string, but pass untrusted values as bind parameters: $1 in the SQL text and an extra argument to fetch_one. This keeps the value out of the SQL string and avoids injection.

CREATE FUNCTION count_at_least(tbl text, threshold int) RETURNS bigint LANGUAGE plxpython3 AS $$
r = fetch_one(f"SELECT count(*) AS c FROM {tbl} WHERE amount >= $1", threshold)
return r.c
$$;
SELECT count_at_least('orders', 100) AS big_orders;
 big_orders
------------
          2
(1 row)

Python idioms: f-strings, range, while

f-strings interpolate expressions with f"{expr}", for i in range(a, b) is the integer loop, and while runs until its condition is false. This function lists the integers up to n and then uses a while loop to find the largest power of two that does not exceed n.

CREATE FUNCTION number_report(n int) RETURNS text LANGUAGE plxpython3 AS $$
s = "" #:: text
for i in range(2, n + 1):
    s += f"{i} "
p = 1 #:: int
while p * 2 <= n:
    p = p * 2
s += f"| largest power of two <= {n} is {p}"
return s
$$;
SELECT number_report(10) AS report;
                        report
------------------------------------------------------
 2 3 4 5 6 7 8 9 10 | largest power of two <= 10 is 8
(1 row)

Iterating an array

for v in values iterates an array argument. The loop variable is annotated with the element type. The annotation v #:: int comes before the loop.

CREATE FUNCTION array_sum(vals int[]) RETURNS bigint LANGUAGE plxpython3 AS $$
total = 0 #:: bigint
v #:: int
for v in vals:
    total = total + v
return total
$$;
SELECT array_sum(ARRAY[3, 1, 4, 1, 5, 9]) AS total;
 total
-------
    23
(1 row)