Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

SQL (PostgreSQL, MySQL, SQLite)  Module 14 – Views, functions, procedures and triggers

Stored functions and procedures

Stored functions and procedures in PostgreSQL and MySQL, from SQL and PL/pgSQL bodies to CALL, and the functions an application registers in SQLite.

  • Intermediate
  • 35 minutes
  • Examples run with sql.js 1.14.2 and Python 3.14.8
  • By MySmartCoPilot
Free core, Pro depth: 13 of 35 minutes free
Free: Functions, procedures and SQLite's application functions · A tax function and a payment procedure in PostgreSQL · The same in MySQL · 2 more sections. With a pass: 2 in-depth parts and 3 interview answers, about 22 minutes.

What you will learn

  • Write SQL and PL/pgSQL functions and call a procedure in PostgreSQL
  • Write a stored function and a stored procedure in MySQL
  • Add an application-defined function to SQLite from Python
  • Choose a function's volatility so that the planner and indexes can trust it

Before you start

A stored function is code kept in the database that takes arguments and returns a value, and a query calls it like a built-in function: SELECT gst_paise(amount_paise, 1800) FROM orders. A stored procedure is called on its own, with CALL, returns no value, and may commit work as it runs. Stored functions and procedures put a rule in one place for every program that uses the database, and they save round trips, because the work runs beside the data. They also move logic out of the application’s code, where it is easier to see, test and change. So keep them for rules that must hold for every caller, such as a tax calculation, and for several changes that belong together. PostgreSQL writes them in SQL, PL/pgSQL and other languages, and MySQL in its own compound statements. SQLite has neither: the program that opens the database registers its functions itself.

Every example here was run for real, with its output recorded: PostgreSQL 18.6 and MySQL 8.4.11 on disposable local servers, and SQLite 3.51.0 from Python 3.14.8.

Functions, procedures and SQLite’s application functions

Stored functions, stored procedures and SQLite's application-defined functions
Criterion FunctionProcedureSQLite function
Called In a query: SELECT f(x)Alone: CALL p(x)In a query
Returns A value, or rowsOUT parameters; in MySQL also rowsA value
Commits NeverYes, from a top-level CALLNever
Kept in The databaseThe databaseThe program, per connection
Engines PostgreSQL, MySQLPostgreSQL 11 and later, MySQLSQLite
Two panels: a database server keeps and runs gst_paise beside the rows; SQLite, inside the application, calls its registered function.PostgreSQL or MySQLSQLiteYour applicationThe database server keepsgst_paise() and runs itbeside the rowsYour application,with SQLite inside itYour own gst_paise(),registered on this connectionSELECT … gst_paise(…)or CALL …: one round tripSQLite calls it for each row,with no network at all

Where the code of a routine runs

Text description of the diagram

The diagram has two panels, one above the other.

  1. PostgreSQL or MySQL: your application sends one statement, SELECT … gst_paise(…) or CALL …, which is one round trip over the network. The database server keeps the function or procedure and runs it there, next to the rows.
  2. SQLite: the database engine runs inside your application and keeps no code of its own. Your application registers its own gst_paise() on the connection, and SQLite calls that function for each row, with no network involved.

Choose a function when queries need a value computed the same way everywhere, such as a tax, a customer’s tier or a cleaned-up phone number. Choose a procedure for a sequence of changes that belongs together, such as recording a payment against its order, or for a job that has to commit as it goes. In SQLite the choice is made for you: only functions exist, and they live in the program.

A tax function and a payment procedure in PostgreSQL

India-specific

The examples compute GST, India’s goods and services tax, on amounts in paise (1 rupee is 100 paise), with the rate in basis points: 1800 is 18 %.

A SQL function, a PL/pgSQL function and a procedure Python · pg/routines.py
# A SQL function, a PL/pgSQL function and a procedure on PostgreSQL 18.6.
from pg_session import psql

psql("""
CREATE TABLE orders (id int PRIMARY KEY, customer_id int NOT NULL, amount_paise bigint NOT NULL, rate_bp int NOT NULL);
INSERT INTO orders VALUES (1, 7, 49900, 1800), (2, 7, 129900, 1800), (3, 8, 9950, 500), (4, 9, 25000, 1200);

-- 1. A SQL function with a standard body: one expression, parsed when the function is created.
CREATE FUNCTION gst_paise(amount_paise bigint, rate_bp integer)
RETURNS bigint
LANGUAGE sql
IMMUTABLE
RETURN round(amount_paise * rate_bp / 10000.0);

SELECT id, amount_paise, rate_bp, gst_paise(amount_paise, rate_bp) AS gst_paise,
       amount_paise + gst_paise(amount_paise, rate_bp) AS total_paise
FROM orders
ORDER BY id;

-- 2. A PL/pgSQL function: IF, ELSIF and an error of its own.
CREATE FUNCTION customer_tier(total_paise bigint)
RETURNS text
LANGUAGE plpgsql
IMMUTABLE
AS $$
BEGIN
  IF total_paise < 0 THEN
    RAISE EXCEPTION 'customer_tier: total_paise cannot be negative (%)', total_paise;
  ELSIF total_paise >= 5000000 THEN
    RETURN 'gold';
  ELSIF total_paise >= 1000000 THEN
    RETURN 'silver';
  END IF;
  RETURN 'standard';
END;
$$;

SELECT t AS total_paise, customer_tier(t) AS tier FROM (VALUES (999999), (1000000), (5000000)) AS v (t);
SELECT customer_tier(-1);

-- 3. A procedure: two writes behind one name, run on its own with CALL.
CREATE TABLE payments (order_id int NOT NULL, paid_paise bigint NOT NULL);
CREATE PROCEDURE record_payment(p_order int, p_paise bigint)
LANGUAGE sql
BEGIN ATOMIC
  INSERT INTO payments VALUES (p_order, p_paise);
  UPDATE orders SET amount_paise = amount_paise - p_paise WHERE id = p_order;
END;

CALL record_payment(2, 29900);
SELECT record_payment(2, 100);
SELECT o.id, o.amount_paise AS still_due_paise, p.paid_paise FROM orders AS o JOIN payments AS p ON p.order_id = o.id;
""")

Output

CREATE TABLE
INSERT 0 4
CREATE FUNCTION
 id | amount_paise | rate_bp | gst_paise | total_paise 
----+--------------+---------+-----------+-------------
  1 |        49900 |    1800 |      8982 |       58882
  2 |       129900 |    1800 |     23382 |      153282
  3 |         9950 |     500 |       498 |       10448
  4 |        25000 |    1200 |      3000 |       28000
(4 rows)

CREATE FUNCTION
 total_paise |   tier   
-------------+----------
      999999 | standard
     1000000 | silver
     5000000 | gold
(3 rows)

ERROR:  customer_tier: total_paise cannot be negative (-1)
CONTEXT:  PL/pgSQL function customer_tier(bigint) line 4 at RAISE
CREATE TABLE
CREATE PROCEDURE
CALL
ERROR:  record_payment(integer, integer) is a procedure
LINE 1: SELECT record_payment(2, 100);
               ^
HINT:  To call a procedure, use CALL.
 id | still_due_paise | paid_paise 
----+-----------------+------------
  2 |          100000 |      29900
(1 row)

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 routines.py

gst_paise uses the SQL-standard body of PostgreSQL 14 and later, RETURN and one expression. It is parsed when it is created, and PostgreSQL records which objects it uses. customer_tier is PL/pgSQL, which adds variables, IF, loops and errors of its own: RAISE EXCEPTION stopped the call for a negative total. record_payment is a procedure, run with CALL, and PostgreSQL refused it inside a SELECT. Every function also carries labels that tell the planner what it may assume:

  • IMMUTABLE promises the same result for the same arguments forever, without reading the database.
  • STABLE promises the same result within one statement, and may read tables.
  • VOLATILE, the default, promises nothing, so PostgreSQL may call the function again for every row.
  • SECURITY DEFINER runs the function with its owner’s privileges instead of the caller’s.

The same in MySQL

A stored function and a procedure with an OUT parameter Python · mysql/routines.py
# A stored function and a stored procedure on MySQL 8.4.11, with binary logging on, as in a new installation.
from mysql_session import mysql

mysql("""
CREATE TABLE products (id INT PRIMARY KEY, name VARCHAR(40) NOT NULL, stock INT NOT NULL);
CREATE TABLE orders (id BIGINT AUTO_INCREMENT PRIMARY KEY, customer_id INT NOT NULL, product_id INT NOT NULL, qty INT NOT NULL);
INSERT INTO products VALUES (3, 'kettle', 5);

-- With binary logging on, a function must say whether it is deterministic or reads no data.
CREATE FUNCTION gst_paise(amount_paise BIGINT, rate_bp INT) RETURNS BIGINT
RETURN ROUND(amount_paise * rate_bp / 10000);
CREATE FUNCTION gst_paise(amount_paise BIGINT, rate_bp INT) RETURNS BIGINT
DETERMINISTIC NO SQL
RETURN ROUND(amount_paise * rate_bp / 10000);
SELECT gst_paise(49900, 1800), gst_paise(9950, 500);

-- A procedure body holds semicolons, so the mysql client must be told where the CREATE statement ends.
DELIMITER //
CREATE PROCEDURE place_order(IN p_customer INT, IN p_product INT, IN p_qty INT, OUT p_order_id BIGINT)
MODIFIES SQL DATA
BEGIN
  DECLARE v_stock INT;
  START TRANSACTION;
  SELECT stock INTO v_stock FROM products WHERE id = p_product FOR UPDATE;
  IF v_stock IS NULL OR v_stock < p_qty THEN
    ROLLBACK;
    SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'place_order: not enough stock';
  END IF;
  UPDATE products SET stock = stock - p_qty WHERE id = p_product;
  INSERT INTO orders (customer_id, product_id, qty) VALUES (p_customer, p_product, p_qty);
  SET p_order_id = LAST_INSERT_ID();
  COMMIT;
END//
DELIMITER ;

-- The OUT parameter comes back through a user variable.
CALL place_order(7, 3, 2, @order_id);
SELECT @order_id AS order_id;
CALL place_order(8, 3, 9, @order_id);
SELECT * FROM products;
""")

Output

ERROR 1418 (HY000) at line 6: This function has none of DETERMINISTIC, NO SQL, or READS SQL DATA in its declaration and binary logging is enabled (you *might* want to use the less safe log_bin_trust_function_creators variable)
+------------------------+----------------------+
| gst_paise(49900, 1800) | gst_paise(9950, 500) |
+------------------------+----------------------+
|                   8982 |                  498 |
+------------------------+----------------------+
+----------+
| order_id |
+----------+
|        1 |
+----------+
ERROR 1644 (45000) at line 35: place_order: not enough stock
+----+--------+-------+
| id | name   | stock |
+----+--------+-------+
|  3 | kettle |     3 |
+----+--------+-------+

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 routines.py

The first CREATE FUNCTION failed with error 1418. A new MySQL 8.4 server writes a binary log, and then it accepts a function only if it says DETERMINISTIC, NO SQL or READS SQL DATA; MySQL trusts the label without checking it. The procedure’s body holds semicolons, so DELIMITER // tells the mysql client where the CREATE PROCEDURE ends. Inside, START TRANSACTION and COMMIT wrap the change, SIGNAL SQLSTATE '45000' raises an error of its own, error 1644 at the client, and the OUT parameter comes back through the user variable @order_id.

SQLite: functions come from the application

gst_paise registered from Python and used in queries Python · gst_udf.py
"""SQLite has no CREATE FUNCTION: the program that opens the database registers its own SQL functions.

gst_paise(amount_paise, rate_bp) is the tax on an amount in paise, at a rate in basis points (1800 = 18 %),
rounded half up to a whole paisa. It exists only on the connection that registered it.
"""
import sqlite3


def gst_paise(amount_paise, rate_bp):
    if amount_paise is None or rate_bp is None:
        return None
    return (amount_paise * rate_bp + 5_000) // 10_000


shop = sqlite3.connect(":memory:")
shop.create_function("gst_paise", 2, gst_paise, deterministic=True)
shop.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, amount_paise INTEGER NOT NULL, rate_bp INTEGER NOT NULL)")
shop.executemany(
    "INSERT INTO orders VALUES (?, ?, ?)",
    [(1, 49_900, 1800), (2, 129_900, 1800), (3, 9_950, 500), (4, 25_000, 1200)],
)

print("id   amount  rate    gst    total")
for row in shop.execute(
    "SELECT id, amount_paise, rate_bp, gst_paise(amount_paise, rate_bp),"
    " amount_paise + gst_paise(amount_paise, rate_bp) FROM orders ORDER BY id"
):
    print("%-3d %7d %5d %6d %8d" % row)

# The function also works in WHERE, GROUP BY and ORDER BY, like a built-in one.
print(shop.execute("SELECT count(*) FROM orders WHERE gst_paise(amount_paise, rate_bp) > 5000").fetchone()[0], "orders pay more than 5000 paise of tax")

# A second connection, even in the same program, does not have it.
other = sqlite3.connect(":memory:")
try:
    other.execute("SELECT gst_paise(49900, 1800)")
except sqlite3.OperationalError as e:
    print("second connection:", e)

Output

id   amount  rate    gst    total
1     49900  1800   8982    58882
2    129900  1800  23382   153282
3      9950   500    498    10448
4     25000  1200   3000    28000
2 orders pay more than 5000 paise of tax
second connection: no such function: gst_paise

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 gst_udf.py

create_function makes gst_paise callable in every kind of expression on that connection, and nowhere else: the second connection, in the same program, had never heard of it. The deterministic=True flag tells SQLite that equal arguments give equal results, which is what an index or a generated column requires of a function. In the browser, sql.js offers the same with db.create_function for JavaScript functions.

Your turn

Check yourself

4 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.

  1. Question 1 of 4 record_payment is a PostgreSQL procedure. What happens when you run SELECT record_payment(2, 100)?

    Choose one answer.

    Show the answer to question 1

    Answer: An error that says it is a procedure, with the hint to use CALL

    Functions are called inside queries and procedures on their own. PostgreSQL refuses a procedure in a SELECT ("is a procedure") and suggests CALL record_payment(2, 100), as the lesson's run shows.

  2. Question 2 of 4 On a new MySQL 8.4 server, CREATE FUNCTION f(x INT) RETURNS INT RETURN x * 2 fails with error 1418. Which change lets it be created without changing any server setting?

    Choose one answer.

    Show the answer to question 2

    Answer: Declaring it DETERMINISTIC (or NO SQL, or READS SQL DATA)

    Binary logging is on in a new installation, and then MySQL accepts a function only if it is declared DETERMINISTIC, NO SQL or READS SQL DATA, because a function that is neither could replicate differently. MySQL does not check the declaration; it trusts it.

  3. Question 3 of 4 A Python program registers gst_paise with create_function on one SQLite connection and then opens a second connection to the same database. What does SELECT gst_paise(49900, 1800) do on the second connection?

    Choose one answer.

    Show the answer to question 3

    Answer: It fails with "no such function", because the function belongs to the connection that registered it

    SQLite stores no function code. An application-defined function exists only on the connection that registered it, so every connection, and every other program, must register it again.

  4. Question 4 of 4 Which volatility does a PostgreSQL function get when CREATE FUNCTION names none?

    Choose one answer.

    Show the answer to question 4

    Answer: VOLATILE

    VOLATILE is the default, and it is the only label that promises nothing: PostgreSQL may call such a function for every row and never folds it into a constant. Marking a function STABLE or IMMUTABLE is a promise you make, and PostgreSQL does not check it.

GST Calculator (India) Check the tax these functions return for any price and rate, with GST added or included.

What the planner trusts: volatility and determinism, with broken indexes reproduced Pro: opens with a Pro, Premium or Ultimate pass

A volatility label is a promise that the database believes and does not check. This part counts how often PostgreSQL calls a function under each label, then breaks an index in PostgreSQL and in SQLite with a function that lies about itself, and shows what an application function in an index does to every other program.

In this part:

  • How often PostgreSQL calls a function under each label, counted
  • A false IMMUTABLE label in an index, reproduced in PostgreSQL and SQLite
  • An application function in an index locks other programs out

3 runnable examples (PostgreSQL 18.6, Python, SQLite 3.51 from Python) · about 3 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Procedures in production: batches, transactions, security and round trips, worked through Pro: opens with a Pro, Premium or Ultimate pass

Procedures earn their keep where several statements must run close to the data and together. This part commits a batch job from a procedure and shows where COMMIT is refused, closes a hole in a SECURITY DEFINER function, and works out when a procedure is worth the round trips it saves.

In this part:

  • Committing in batches, and where COMMIT is refused
  • SECURITY DEFINER without a borrowed table
  • Round trips, worked through: a checkout as nine statements or one CALL

3 runnable examples (Python) · about 3 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Interview questions

  1. Fresher to mid · Warm-up

    What is the difference between a stored function and a stored procedure?

    A stored function takes arguments and returns a value, or a set of rows, and is called inside a query wherever an expression may stand: SELECT gst_paise(amount_paise, 1800) FROM orders. A stored procedure is run on its own, with CALL, returns no value, and hands results back through output parameters (in MySQL also as result sets). The bigger difference is transactions: a procedure called at the top level may COMMIT or ROLLBACK as it runs, which PostgreSQL has allowed since version 11, while a function always runs inside the caller's transaction and may not end it. So functions suit calculations and lookups that queries need, and procedures suit multi-step changes and batch jobs. SQLite has neither; a program registers its own functions on each connection instead.

  2. Mid to senior

    A PostgreSQL function rate_for(category) reads a gst_rates table and is marked IMMUTABLE so that an expression index can use it. After the electronics rate changes, a search by rate still returns electronics items at the old rate, and nothing reports an error. Explain what happened, and fix it, including the results already affected.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  3. Senior to staff

    Checkout runs nine SQL statements from an application server in one region against a database 30 ms away in another. During a sale, orders for one popular product queue up although the database is nearly idle. A colleague proposes moving checkout into a stored procedure. Argue for and against with numbers, and decide.

    Model answer, Pro. First step and follow-ups

  4. Mid to senior

    A Python import job registers gst_paise on its SQLite connection and creates an index on gst_paise(amount_paise, rate_bp). Since then the team's reporting tool and the sqlite3 shell can read the orders table but fail on every INSERT, UPDATE and DELETE. Why, and what are your options?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • Stored functions return a value and are called inside queries; procedures are run with CALL and may commit as they go.
  • PostgreSQL writes routines in SQL, with a standard body that is parsed at creation, or in PL/pgSQL for variables, conditions and errors.
  • MySQL needs DELIMITER in its client for bodies with semicolons, and, with binary logging on, a DETERMINISTIC, NO SQL or READS SQL DATA label on every function.
  • SQLite stores no code: an application registers functions per connection, with create_function in Python or sql.js.
  • A function’s labels, IMMUTABLE, STABLE or VOLATILE, tell the planner what it may assume, and the database does not check them.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.