Everything you've written so far is a query: you send it, it runs and it's forgotten. This lesson is about the opposite — named code that lives inside the database and is invoked as if it were part of the language. This is where the order confirmation you wrote by hand in 09-03 will end up living, with its BEGIN, its four statements and its COMMIT, and which until now depended on whoever ran it not skipping a step.
You'll see the real difference between a function and a procedure in PostgreSQL 11+, just enough PL/pgSQL to write something useful without turning this into a programming course, and three GreenStore examples in order of difficulty ending in sp_confirm_order. And, above all, the honest discussion: what a team gains by putting logic in the database, what it loses, and where the line is. Because this is the tool in the module that's easiest to misuse.
Contents
- Function versus procedure
CREATE FUNCTIONwithLANGUAGE sql- PL/pgSQL in one reference table
- Parameters, return types, overloading and dropping
fn_order_total: a scalar functionfn_sales_by_category: a function that returns a tablesp_confirm_order: 09-03's procedure- Volatility and
SECURITY DEFINER - The honest discussion: what to put inside and what not
- Common Mistakes and Tips
- Exercises
- Conclusion
- Function versus procedure
Up to PostgreSQL 10 there were only functions, and they were used for everything. From 11 on there are procedures too, and the confusion between the two is constant. The table that settles it:
Function (CREATE FUNCTION) |
Procedure (CREATE PROCEDURE) |
|
|---|---|---|
| Invoked with | SELECT fn(...) or inside a query |
CALL sp(...), as a standalone statement |
| Returns | Always something: scalar, row, table or void |
Nothing (or INOUT) |
Can it be used in a SELECT? |
Yes: it's just another expression | No |
| Does it control transactions? | No. It runs inside the caller's | Yes: it can COMMIT and ROLLBACK |
| Declarable volatility | Yes (IMMUTABLE/STABLE/VOLATILE) |
No |
| What it's for | Computing a value or returning a set | Running a process: steps, batches, maintenance |
The dividing line is transaction control. A function runs inside the transaction of whoever calls it; if that transaction is rolled back, everything the function did is rolled back with it. A procedure called with CALL outside an explicit transaction can commit on its own, which lets you write a batch process that commits every thousand rows without keeping one giant transaction open (09-01).
The practical rule: if it returns a value, function; if it does a job, procedure.
CREATE FUNCTION with LANGUAGE sql
CREATE FUNCTION with LANGUAGE sqlThe simplest form needs no procedural language at all: it's a query with a name and parameters.
CREATE OR REPLACE FUNCTION fn_revenue_by_country(p_country TEXT)
RETURNS NUMERIC LANGUAGE sql STABLE AS $$
SELECT COALESCE(ROUND(SUM(ol.quantity * ol.unit_price * (1 - ol.discount)), 2), 0)
FROM order_lines AS ol
JOIN orders AS o ON o.id = ol.order_id
JOIN customers AS c ON c.id = o.customer_id
WHERE c.country = p_country;
$$;
SELECT fn_revenue_by_country('Spain') AS spain,
fn_revenue_by_country('Portugal') AS portugal,
fn_revenue_by_country('France') AS france;| spain | portugal | france |
|---|---|---|
| 433.70 | 156.48 | 137.77 |
433.70 + 156.48 + 137.77 = €727.95: the per-country figures you already computed in 07-04, now encapsulated. Four syntax details that will recur throughout the lesson:
$$ ... $$delimits the body. It's 01-03's dollar quoting: inside it you can write single quotes without escaping them. If the body contains$$, use a tag:$fn$ ... $fn$.- Parameters are named with a prefix (
p_country) by convention, so they don't clash with column names. If a parameter were calledcountry, PostgreSQL wouldn't know which one you meant in theWHERE. RETURNS NUMERICdeclares the type. WithLANGUAGE sql, the result is that of the last statement.STABLEis the volatility (section 8).
(Every function and procedure in this lesson is an example: none of them is part of the canonical GreenStore schema from 01-06.)
- PL/pgSQL in one reference table
LANGUAGE sql can't decide or repeat. That's what PL/pgSQL is for, the procedural language PostgreSQL ships with. Here's the essential —just enough to write something useful— and you won't need more for this lesson:
| Element | Syntax | Note |
|---|---|---|
| Structure | DECLARE ... BEGIN ... EXCEPTION ... END; |
The DECLARE block is optional; BEGIN/END are not a transaction's |
| Declaring | v_total NUMERIC(10,2) := 0; |
Explicit type, or %TYPE / %ROWTYPE to copy it from a column or table |
| Assigning | v_total := 12.50; |
With :=. And SELECT ... INTO v_total to assign from a query |
| Conditional | IF cond THEN ... ELSIF cond THEN ... ELSE ... END IF; |
Careful: it's ELSIF, not ELSEIF or ELSE IF |
| Loop over a query | FOR r IN SELECT ... LOOP ... END LOOP; |
r declares itself and takes the row's type |
| Other loops | LOOP ... EXIT WHEN cond; END LOOP;, WHILE, FOR i IN 1..10 |
|
| Returning | RETURN expr; · RETURN QUERY SELECT ...; · RETURN NEXT r; |
RETURN QUERY for set-returning functions |
| Messages | RAISE NOTICE 'stock: %', v_stock; |
% is the substitution marker. Levels: DEBUG, LOG, NOTICE, WARNING |
| Error | RAISE EXCEPTION 'No stock for %', v_id USING ERRCODE = 'P0001'; |
Aborts the transaction |
| Catching | EXCEPTION WHEN unique_violation THEN ... WHEN OTHERS THEN ... |
|
| Rows affected | GET DIAGNOSTICS v_n = ROW_COUNT; |
05-03's UPDATE N, now in a variable |
| Implicit variables | FOUND (did the last query find anything?), SQLERRM, SQLSTATE |
Two warnings that save hours. The first: BEGIN and END in PL/pgSQL delimit a block of code, not a transaction; the word is the same and it confuses everybody. The second: a block with an EXCEPTION internally creates a SAVEPOINT (09-03) and has a cost, so don't wrap a million-iteration loop in an EXCEPTION unless you need to.
- Parameters, return types, overloading and dropping
| Aspect | Forms |
|---|---|
| Parameter modes | IN (the default), OUT (returns through it), INOUT (in and out), VARIADIC (variable number) |
| Default value | p_year INTEGER DEFAULT 2025 — the ones with a default must go last |
| Call by name | fn_sales_by_category(p_year => 2026), very readable with several parameters |
| Scalar return | RETURNS NUMERIC, RETURNS TEXT, RETURNS void |
| Set return | RETURNS SETOF products (rows of an existing table) · RETURNS TABLE(col type, ...) (defines the columns right there) |
| Overloading | Several functions with the same name and different parameters. Resolved by type |
| Dropping | DROP FUNCTION fn_order_total(INTEGER); — you have to give the signature if it's overloaded |
RETURNS TABLE(...) versus RETURNS SETOF record: the first declares the column names and types inside the function, and the caller simply writes SELECT * FROM fn(...). The second forces you to describe the structure on every call —SELECT * FROM fn(...) AS t(id INT, name TEXT)— which is awkward and fragile. Use RETURNS TABLE unless the shape of the result genuinely depends on the arguments.
On overloading, a warning: it's convenient but it turns treacherous with types. fn(1) and fn(1.0) can resolve to different functions, and DROP FUNCTION fn without a signature gives function name "fn" is not unique. Prefer different names over overloading, unless the overload is obvious.
fn_order_total: a scalar function
fn_order_total: a scalar functionThe first example, and the one you'll use most: an order's total, with the canonical amount expression.
CREATE OR REPLACE FUNCTION fn_order_total(p_order_id INTEGER)
RETURNS NUMERIC(10,2) LANGUAGE plpgsql STABLE AS $$
DECLARE
v_total NUMERIC(10,2);
BEGIN
SELECT COALESCE(ROUND(SUM(ol.quantity * ol.unit_price * (1 - ol.discount)), 2), 0)
INTO v_total
FROM order_lines AS ol
WHERE ol.order_id = p_order_id;
IF v_total IS NULL THEN
RAISE NOTICE 'Order % has no lines', p_order_id;
RETURN 0;
END IF;
RETURN v_total;
END;
$$;And here's the charm of it: it's used like any other column, inside any query.
SELECT o.id, c.name || ' ' || c.last_name AS customer, o.status,
fn_order_total(o.id) AS products_total,
o.shipping_cost,
fn_order_total(o.id) + o.shipping_cost AS order_total
FROM orders AS o
JOIN customers AS c ON c.id = o.customer_id
WHERE o.id IN (1, 8, 12, 20)
ORDER BY o.id;| id | customer | status | products_total | shipping_cost | order_total |
|---|---|---|---|---|---|
| 1 | Lucía Martínez Soler | delivered | 42.10 | 4.95 | 47.05 |
| 8 | Sofia Moreira Costa | delivered | 64.88 | 9.90 | 74.78 |
| 12 | Julien Moreau | delivered | 66.90 | 12.50 | 79.40 |
| 20 | Camille Dubois | pending | 22.60 | 12.50 | 35.10 |
The four totals match those in 07-04: order 12 is the highest order value (€66.90) and order 20 the lowest (€22.60).
And here's the danger, stated up front:
SELECT id, fn_order_total(id) FROM ordersruns the function once per row — 20 independent queries againstorder_lines. It's exactly 07-02's correlated subquery, now hidden behind a pretty name. For a 20-order report it doesn't matter; for a two-million-order one, it's the difference between 200 ms and half an hour. A scalar function inside a bulkSELECTis aJOINin disguise, and it's almost always better to write theJOIN(or use 10-01's view).
fn_sales_by_category: a function that returns a table
fn_sales_by_category: a function that returns a tableWith RETURNS TABLE, a function behaves like a parameterized table — something halfway between a view and a standalone query, and something views can't do because a view takes no parameters.
CREATE OR REPLACE FUNCTION fn_sales_by_category(p_year INTEGER DEFAULT 2025)
RETURNS TABLE (category_id INTEGER, category VARCHAR, units BIGINT, revenue NUMERIC)
LANGUAGE plpgsql STABLE AS $$
BEGIN
RETURN QUERY
SELECT cat.id, cat.name, SUM(ol.quantity),
ROUND(SUM(ol.quantity * ol.unit_price * (1 - ol.discount)), 2)
FROM order_lines AS ol
JOIN orders AS o ON o.id = ol.order_id
JOIN products AS p ON p.id = ol.product_id
JOIN categories AS cat ON cat.id = p.category_id
WHERE EXTRACT(YEAR FROM o.order_date) = p_year
GROUP BY cat.id, cat.name
ORDER BY 4 DESC;
END;
$$;
SELECT * FROM fn_sales_by_category(2025);| category_id | category | units | revenue |
|---|---|---|---|
| 1 | Food | 43 | 215.67 |
| 4 | Drinks | 22 | 146.55 |
| 2 | Natural cosmetics | 13 | 128.22 |
| 3 | Sustainable home | 10 | 88.58 |
| 5 | Personal hygiene | 7 | 24.50 |
They add up to €603.52, 2025's revenue. And with SELECT * FROM fn_sales_by_category(2026) only four categories come out —Drinks €48.73, Food €40.60, Natural cosmetics €28.10 and Personal hygiene €7.00— adding up to 2026's €124.43: in the first two months of the year nothing from Sustainable home has been sold.
Like any table, it can be filtered and joined: SELECT * FROM fn_sales_by_category(2025) WHERE revenue > 100; returns three rows.
When a function and when a view (10-01): if the result doesn't depend on any parameter, a view. If it depends on an argument —the year, the country, a date range— a function returning a table. And if the parameter only serves to filter a column the view already exposes, a view plus a
WHERE, which the planner optimizes better.
sp_confirm_order: 09-03's procedure
sp_confirm_order: 09-03's procedureThis is the destination 09-03 and 09-05 announced. Confirming an order is several operations that have to go together: create the header, insert the lines, deduct the stock checking it doesn't go negative and mark the order as paid. If something fails, nothing must be left half-done.
CREATE OR REPLACE PROCEDURE sp_confirm_order(
p_customer_id INTEGER,
p_employee_id INTEGER,
p_payment_method VARCHAR,
p_shipping NUMERIC,
p_products INTEGER[], -- product ids
p_quantities INTEGER[], -- quantities, in the same order
INOUT p_order_id INTEGER DEFAULT NULL -- returns the id created
)
LANGUAGE plpgsql
AS $$
DECLARE
v_i INTEGER; v_price NUMERIC(10,2); v_stock INTEGER; v_name TEXT;
BEGIN
IF array_length(p_products, 1) IS DISTINCT FROM array_length(p_quantities, 1) THEN
RAISE EXCEPTION 'The product and quantity lists are not the same size';
END IF;
-- 1. Order header
INSERT INTO orders (customer_id, employee_id, order_date, status, payment_method, shipping_cost)
VALUES (p_customer_id, p_employee_id, CURRENT_DATE, 'pending', p_payment_method, p_shipping)
RETURNING id INTO p_order_id;
-- 2. One line per product, deducting stock
FOR v_i IN 1 .. array_length(p_products, 1) LOOP
-- Lock the product row before deciding (09-05's pattern)
SELECT p.price, p.stock, p.name INTO v_price, v_stock, v_name
FROM products AS p
WHERE p.id = p_products[v_i] AND p.active
FOR UPDATE;
IF NOT FOUND THEN
RAISE EXCEPTION 'Product % does not exist or is discontinued', p_products[v_i];
END IF;
IF v_stock < p_quantities[v_i] THEN
RAISE EXCEPTION 'Not enough stock of "%": % left and % requested',
v_name, v_stock, p_quantities[v_i] USING ERRCODE = 'P0001';
END IF;
INSERT INTO order_lines (order_id, product_id, quantity, unit_price, discount)
VALUES (p_order_id, p_products[v_i], p_quantities[v_i], v_price, 0);
UPDATE products SET stock = stock - p_quantities[v_i] WHERE id = p_products[v_i];
RAISE NOTICE 'Line added: % x % at %', p_quantities[v_i], v_name, v_price;
END LOOP;
-- 3. The order is now paid
UPDATE orders SET status = 'paid' WHERE id = p_order_id;
RAISE NOTICE 'Order % confirmed for a total of %', p_order_id, fn_order_total(p_order_id);
END;
$$;The call, with the happy case and the failure case:
-- 2 units of olive oil (id 1) and 1 of matcha (id 15) for Lucía, handled by Óscar
CALL sp_confirm_order(1, 4, 'card', 4.95, ARRAY[1, 15], ARRAY[2, 1]);NOTICE: Line added: 2 x Extra virgin olive oil 500 ml at 12.50 NOTICE: Line added: 1 x Ceremonial matcha green tea 30 g at 22.00 NOTICE: Order 21 confirmed for a total of 47.00 CALL
-- Now ordering more candles (id 13, stock 0) than there are
CALL sp_confirm_order(1, 4, 'card', 4.95, ARRAY[13], ARRAY[1]);ERROR: Not enough stock of "Soy wax candles (pack of 2)": 0 left and 1 requested CONTEXT: PL/pgSQL function sp_confirm_order(...) line 34 at RAISE
And order 22 doesn't exist. That's the whole lesson of this section: the RAISE EXCEPTION aborted the transaction, and the header that had already been inserted was rolled back with it. 09-02's atomicity is guaranteed by construction, not by the caller's discipline. A CALL outside an explicit transaction runs in its own implicit transaction, so the procedure is atomic without writing a BEGIN or a COMMIT.
Compare it with what you had in 09-03: there, the application had to open the transaction, run the four statements in the right order, check the stock's UPDATE N and decide whether to commit. Four places to get it wrong, multiplied by every application that confirms orders. Here there's one.
(This procedure modifies the data. If you try it, reload 01-06's script afterwards to get back to the 20 rows in orders and the original stock levels.)
When a procedure really does need a COMMIT
The explicit COMMIT has one clear case: the batch process. A procedure that recomputes a million rows and commits every thousand avoids keeping a transaction open for hours —with the cost in VACUUM and locks 09-01 explained:
LOOP
UPDATE ... WHERE ... LIMIT 1000; -- one batch
EXIT WHEN NOT FOUND;
COMMIT; -- only possible in a PROCEDURE
END LOOP;It's exactly what a function can't do, and the reason procedures were added to the engine.
- Volatility and
SECURITY DEFINER
SECURITY DEFINEREvery function declares —explicitly or implicitly— how much the planner can trust it:
| Category | Promises | Examples | Consequence |
|---|---|---|---|
IMMUTABLE |
Same arguments → always the same result. Reads no tables | lower(x), x * 1.21 |
Can be precomputed and used in an expression index |
STABLE |
The same result within a single statement. May read tables, not write | fn_order_total, now() |
Evaluated once per statement where possible |
VOLATILE |
Anything: it may return something different on every call and may write | random(), anything that does an INSERT |
Evaluated on every row, always. It's the default |
VOLATILE being the default is the most expensive silent mistake in this lesson. A function that only reads and declares nothing is evaluated row by row and can't be indexed, so this is where 08-02's promise gets kept: an expression index like CREATE INDEX ix_prod_lower ON products (lower(name)); requires the function to be IMMUTABLE. If it isn't, PostgreSQL answers functions in index expression must be marked IMMUTABLE — because an index stores precomputed results and only makes sense if those results don't change.
Mark every read-only function STABLE and every pure-computation function IMMUTABLE. And don't lie to the planner: declaring something IMMUTABLE when it reads a table produces incorrect results that are hard to diagnose.
SECURITY DEFINER. By default a function runs with the permissions of whoever calls it (SECURITY INVOKER). WithSECURITY DEFINERit runs with those of whoever created it, which lets you give controlled access to data the user can't read directly. It's powerful and it's an attack surface: badly written, with a manipulablesearch_path, it's a textbook privilege escalation. Permissions, roles and how to harden these functions are the subject of 11-03.
- The honest discussion: what to put inside and what not
This is the most valuable part of the lesson, and where most tutorials go quiet.
| In favour | Against |
|---|---|
| A single round trip: confirming an order is 1 call instead of 6 journeys over the network | Hard to version: the code lives in the catalogue, not in your repository, unless you impose discipline |
| The logic sits next to the data: no transferring thousands of rows to process them elsewhere | Hard to test: there's no decent debugger and no testing ecosystem comparable to your language's |
| Atomicity guaranteed by construction, not by the caller's discipline | Logic split across two places: when debugging you have to look at the application and the database |
| Reuse across several applications and languages against the same database | Zero portability: PL/pgSQL, T-SQL and PL/SQL are nothing alike. Migrating engines means rewriting |
| A single point of truth for a critical business rule | Scales with the DB server, which is the most expensive and hardest piece to replicate |
| It can restrict what a user does to a set of operations | ORMs don't use them well: they become a parallel path (11-05) |
And the criterion, which is what you should take away:
| Put it in the database when… | Leave it in the application when… |
|---|---|
| It's an integrity rule no application should be able to bypass | It's presentation, flow or user-experience logic |
| It requires atomicity across several tables | It requires calling external services (payment gateway, email, API) |
| It processes many rows and pulling them out would be absurd | It changes often and needs the application's deployment cycle |
| It's shared by several applications on the same database | Only one application uses it |
| It's maintenance of the database itself (cleanups, aggregates, partitions) | It needs libraries, concurrency or computation SQL isn't good at |
The balanced position, and the one most teams hold today: little code in the database, and only good code. Constraints and CHECKs always; views and computation functions, no problem; procedures for what genuinely demands atomicity or bulk processing. What doesn't work is either extreme: neither an anaemic application that just calls two hundred procedures, nor a database treated as a dumb store in which no rule can be guaranteed.
Dialect note: this is where the engines diverge most.
Engine Language Quirk PostgreSQL PL/pgSQL (and PL/Python, PL/Perl, PL/v8…) CREATE FUNCTION/CREATE PROCEDURE;CALL; body between$$MySQL 8 SQL/PSM You need DELIMITER //before creating the procedure, because the internal;would cut the statement shortSQL Server T-SQL CREATE PROCEDURE ... AS BEGIN ... END,EXEC,@variables,TRY...CATCHOracle PL/SQL PL/pgSQL's ancestor: they're very alike, but packages ( PACKAGE) have no equivalentSQLite — It has no stored procedures or functions. Only functions defined by the application when it opens the connection
Common Mistakes and Tips
- Calling a procedure with
SELECT. It isn't an expression:CALL sp_confirm_order(...). And the other way round,CALLon a function errors out. - Expecting a
COMMITinside a function to work. It can't:cannot commit while a subtransaction is activeorinvalid transaction termination. Only procedures, and only if theCALLisn't inside an explicit transaction. - Leaving the volatility at its default. Everything is
VOLATILEif you say nothing: it's re-evaluated row by row and is no use for an expression index (functions in index expression must be marked IMMUTABLE). Mark read-only thingsSTABLE. - Calling a scalar function over millions of rows. It's a correlated subquery in disguise: one execution per row. Rewrite it as a
JOIN, a view or a table-returning function. - Giving a parameter the same name as a column. The ambiguity resolves in favour of the parameter and the
WHEREstops filtering. Always prefix withp_. - Confusing PL/pgSQL's
BEGINwith a transaction's. It delimits a block of code. The transaction is the caller's. - Using
EXCEPTION WHEN OTHERS THEN NULL. It silences every error, including the ones you should never have silenced. Catch specific exceptions and re-raise whatever you don't know how to handle. - Writing
ELSEIForELSE IF. In PL/pgSQL it'sELSIF. - Tip: keep the code in the repository, in
.sqlfiles withCREATE OR REPLACE, and deploy it with your migrations. A function that only exists in production doesn't exist. - Tip: start with
LANGUAGE sql. If you don't need to decide or repeat, don't use PL/pgSQL: the pure-SQL version is shorter, faster and the planner can inline it into the query that calls it. - Tip:
RAISE NOTICEis your debugger. There isn't much else, and aRAISE NOTICE 'v_stock = %', v_stock;at the key points solves 90 % of problems.
Exercises
Exercise 1
Write fn_average_rating(p_product_id), returning a product's average rating rounded to two decimals, or NULL if it has no reviews. (1) Choose LANGUAGE sql or plpgsql and justify it. (2) Declare the right volatility. (3) Use it in a query listing the 20 products with their average, and check that the olive oil (product 1) comes out with 5.00, the rice with 4.50 and eleven products with NULL.
Exercise 2
On sp_confirm_order, reason out your answers and then check them. (1) If the second product in the list is out of stock, does the first line stay inserted? And the order header? Why? (2) What would happen if you removed the FOR UPDATE from the SELECT on products and two customers confirmed the last unit of matcha at the same time? (3) Could you turn it into a function instead of a procedure? What would you lose?
Exercise 3
The team is arguing about where to put four GreenStore rules. Decide, using section 9's criterion, whether they go in the database or in the application, and with exactly which tool:
- "The sale price can never be negative."
- "When an order is confirmed, a confirmation email must be sent to the customer."
- "Every night the ranking of the month's best-selling products has to be recomputed."
- "A customer can't have more than three pending orders at a time."
Solutions
Solution 1
CREATE OR REPLACE FUNCTION fn_average_rating(p_product_id INTEGER)
RETURNS NUMERIC(3,2) LANGUAGE sql STABLE AS $$
SELECT ROUND(AVG(r.rating), 2) FROM reviews AS r WHERE r.product_id = p_product_id;
$$;
SELECT p.id, p.name, fn_average_rating(p.id) AS avg_rating
FROM products AS p ORDER BY avg_rating DESC NULLS LAST, p.id;| id | name | avg_rating |
|---|---|---|
| 1 | Extra virgin olive oil 500 ml | 5.00 |
| 15 | Ceremonial matcha green tea 30 g | 5.00 |
| 2 | Organic brown rice 1 kg | 4.50 |
| 6 | Aloe vera face cream 50 ml | 4.50 |
(First 4 of 20 rows; next come the detergent and the toothbrush with 4.00, the tomato and the bags with 3.00, the kombucha with 2.00 and eleven products with NULL.)
1. LANGUAGE sql: there's no decision and no loop, just a query. It's shorter and the planner can inline it into the calling query, which doesn't happen with plpgsql. 2. STABLE: it reads tables, so it can't be IMMUTABLE; and it doesn't write, so declaring it VOLATILE would waste optimizations. 3. AVG over an empty set returns NULL with no need for any IF — and here NULL is the right answer: "no opinions" isn't the same as "zero stars" (04-03).
Solution 2
1. Nothing is left: neither the line nor the header. The RAISE EXCEPTION aborts the whole transaction, and since the CALL ran in its own implicit transaction, everything done since the start of the procedure is undone — including the first line, which was already inserted, and the UPDATE that had already deducted its stock. It's 09-02's atomicity applied without anyone having to remember to write a ROLLBACK. What does get consumed is the value from the orders.id sequence, because of 09-05: nextval isn't transactional.
2. It would be 09-04's lost update, exactly. Without FOR UPDATE, both sessions would read stock = 1, both would pass the IF v_stock < quantity, and both would insert their line: two units would be sold out of one. The table's CHECK (stock >= 0) would save the day in this specific case —the second UPDATE would fail trying to leave the stock at −1— but that's luck, not design. With FOR UPDATE, the second session waits, rereads stock = 0 and raises its not-enough-stock exception, which is the right message for the customer.
3. Yes, and it would work just as well in this case, because all the logic fits in a single transaction: a CREATE FUNCTION ... RETURNS INTEGER returning the order id would do, run with a SELECT. What you'd lose is the ability to make intermediate COMMITs, irrelevant here but decisive if one day the procedure had to confirm a thousand orders in batches. You gain something in exchange: a function can be used inside a query. The honest choice is section 1's: this does a job, it doesn't compute a value, so procedure.
Solution 3
| # | Rule | Where | Tool |
|---|---|---|---|
| 1 | Non-negative price | Database | A CHECK (price >= 0) (already in 01-06's schema). Neither function nor procedure: it's pure integrity and the declarative constraint always wins |
| 2 | Confirmation email | Application | It's a call to an external service. Inside a transaction it would be disastrous: if the transaction is rolled back, the email has already gone out and can't be unsent (09-01) |
| 3 | Recompute the ranking every night | Database | A materialized view with a scheduled REFRESH (10-01), or a procedure called from cron. It processes many rows and pulling them out would be absurd |
| 4 | Maximum three pending orders | It depends | A CHECK can't (it queries another table). If it's an inviolable rule, it goes inside: a trigger (10-05) or sp_confirm_order itself. If it's a commercial policy that changes every quarter, better in the application |
The fourth is the interesting one and has no single answer: the right question isn't "can it be done?" but "what happens if somebody bypasses it?". If the answer is "corrupt data", it goes in the database; if it's "an odd shopping experience", it goes in the application.
Conclusion
Code that lives in the database, with its two faces:
- Function versus procedure: a function returns a value, is called with
SELECT, can be used inside a query and doesn't control transactions; a procedure is called withCALL, returns nothing (except throughINOUT) and canCOMMIT/ROLLBACK. If it returns a value, function; if it does a job, procedure. LANGUAGE sqlis enough to encapsulate a query with parameters, and it's the first choice. PL/pgSQL addsDECLARE,IF/ELSIF,FOR ... IN SELECT,RETURN QUERY,RAISE NOTICE/EXCEPTIONand theEXCEPTION WHENblock — with the warning that itsBEGINis not a transaction's.RETURNS TABLE(...)turns a function into a parameterized table, which is what a view can never be. If there are no parameters, view; if there are, function.- The three examples:
fn_order_total(scalar, with the order values 42.10, 64.88, 66.90 and €22.60),fn_sales_by_category(table, €603.52 in 2025 and €124.43 in 2026) andsp_confirm_order, where 09-03's logic finally lives: it inserts, deducts stock withFOR UPDATE, raises aRAISE EXCEPTIONif there isn't enough and leaves the database untouched if anything fails. - Volatility matters:
VOLATILEis the default and means "re-evaluate me on every row";IMMUTABLEis a requirement for an expression index (08-02) andSTABLEis right for nearly every read-only function.SECURITY DEFINERgives power and opens an attack surface (11-03). - And the honest discussion: one call instead of six journeys, logic next to the data, guaranteed atomicity and reuse, against difficult versioning, awkward testing, logic in two places, zero portability and scaling tied to the DB server. The sensible position is little code in the database, and only good code.
There's one question this scheme doesn't answer: what if the logic has to run without anyone calling it? A procedure has to be invoked, and all it takes is one application writing straight into the table to bypass the whole thing. In the next lesson, triggers, you'll see code that fires by itself when something happens: BEFORE or AFTER an INSERT, UPDATE or DELETE, with NEW and OLD, and with the ability to modify or cancel the operation in progress. With it you'll build the price-change audit trail, keep a denormalized total up to date, stop a customer from reviewing a product they haven't bought —the rule 09-02 left beyond a CHECK's reach— and deduct stock automatically. And you'll see its dangers in the same detail, because a trigger is the only piece in this course capable of making an UPDATE do something you didn't write.
SQL Course
Module 1: Introduction to SQL
- What is SQL?
- Setting up your SQL environment
- Basic SQL syntax
- Understanding databases and tables
- The relational model: primary and foreign keys
- The course database: GreenStore
Module 2: Basic SQL queries
- The SELECT statement
- Aliases, expressions and calculated columns
- Filtering data with WHERE
- DISTINCT and removing duplicates
- Sorting data with ORDER BY
- Limiting results with LIMIT
Module 3: Working with multiple tables
- JOIN operations
- INNER JOIN
- LEFT JOIN
- RIGHT JOIN
- FULL OUTER JOIN
- SELF JOIN and CROSS JOIN
- Set operations: UNION, INTERSECT and EXCEPT
Module 4: Advanced data filtering
- Using LIKE for pattern matching
- The IN and BETWEEN operators
- NULL values and IS NULL
- Aggregate functions: COUNT, SUM, AVG, MIN and MAX
- Aggregating data with GROUP BY
- The HAVING clause
Module 5: Data manipulation
- Creating tables and constraints with CREATE TABLE
- The INSERT statement
- The UPDATE statement
- The DELETE statement
- The UPSERT (MERGE) statement
- Changing the schema: ALTER TABLE and safe migrations
Module 6: Advanced SQL functions
- String functions
- Numeric functions
- Date and time functions
- Type conversion and handling NULL: CAST and COALESCE
- Conditional expressions
Module 7: Subqueries and nested queries
- Introduction to subqueries
- Correlated subqueries
- EXISTS and NOT EXISTS
- Using subqueries in SELECT, FROM and WHERE
- Subquery or JOIN: which one to choose
Module 8: Indexes and performance tuning
- Understanding indexes
- Creating and managing indexes
- Index types and when not to index
- Query optimization techniques
- Analyzing query performance
Module 9: Transactions and concurrency
- Introduction to transactions
- ACID properties
- Transaction control statements
- Isolation levels and concurrency anomalies
- Handling concurrency: locks and deadlocks
Module 10: Advanced topics
- Views
- Common table expressions (CTEs)
- Window functions
- Stored procedures
- Triggers
- JSON and semi-structured data
Module 11: SQL in practice
- Real-world use cases
- Best practices
- Security: SQL injection, permissions and roles
- SQL for data analysis
- SQL in web development
