PostgreSQL's Swiss Army knife of built-in functions, plus how to create your own reusable SQL tools.
Imagine you have a box of fridge magnets — each magnet is a letter. String functions are like a set of magic tools for those magnets: one tool makes every letter UPPERCASE, another trims off blank magnets from the edges, another slices out just the first three letters. PostgreSQL ships with dozens of these built-in text-manipulation tools so you never have to do the work in your application code.
SELECT UPPER('hello world') AS uppered, LOWER('HELLO WORLD') AS lowered;
| uppered | lowered |
|---|---|
| HELLO WORLD | hello world |
SELECT LENGTH('PostgreSQL') AS char_count; -- Result: 10
-- SUBSTRING(string FROM start FOR length) SELECT SUBSTRING('PostgreSQL' FROM 1 FOR 8) AS result; -- Result: PostgreS -- Also works with standard syntax SELECT SUBSTRING('PostgreSQL', 8, 3) AS result; -- Result: SQL
SELECT CONCAT('Hello', ' ', 'World') AS greeting; -- Result: Hello World -- PostgreSQL also supports the || operator SELECT 'Hello' || ' ' || 'World' AS greeting; -- Result: Hello World
CONCAT treats NULL as an empty string: CONCAT('Hi', NULL, '!') → 'Hi!'. But || propagates NULL: 'Hi' || NULL || '!' → NULL. Use CONCAT when your data might have NULLs!
SELECT TRIM(' hello ') AS trimmed, LTRIM(' hello ') AS left_trimmed, RTRIM(' hello ') AS right_trimmed;
| trimmed | left_trimmed | right_trimmed |
|---|---|---|
| 'hello' | 'hello ' | ' hello' |
SELECT REPLACE('Hello World', 'World', 'PostgreSQL') AS result; -- Result: Hello PostgreSQL
-- SPLIT_PART(string, delimiter, position) SELECT SPLIT_PART('john.doe@gmail.com', '@', 1) AS username, SPLIT_PART('john.doe@gmail.com', '@', 2) AS domain;
| username | domain |
|---|---|
| john.doe | gmail.com |
SELECT LEFT('PostgreSQL', 4) AS first_four, RIGHT('PostgreSQL', 3) AS last_three;
| first_four | last_three |
|---|---|
| Post | SQL |
SELECT POSITION('SQL' IN 'PostgreSQL') AS found_at; -- Result: 8 (1-based index)
SELECT LPAD('42', 5, '0') AS invoice_num, RPAD('Hi', 10, '.') AS dotted;
| invoice_num | dotted |
|---|---|
| 00042 | Hi........ |
Imagine you run an e-commerce store and customers type their names in all sorts of crazy ways: " jOhN dOe ". You'd chain functions like this: INITCAP(TRIM(name)) to get "John Doe". String functions are essential for data cleaning.
| Function | What It Does | Example → Result |
|---|---|---|
UPPER(s) | All uppercase | UPPER('hi') → HI |
LOWER(s) | All lowercase | LOWER('HI') → hi |
INITCAP(s) | Capitalize first letters | INITCAP('hello world') → Hello World |
LENGTH(s) | Character count | LENGTH('abc') → 3 |
TRIM(s) | Remove leading/trailing spaces | TRIM(' hi ') → hi |
REPLACE(s,a,b) | Replace a with b | REPLACE('abc','b','X') → aXc |
SPLIT_PART(s,d,n) | Split by delimiter, get nth | SPLIT_PART('a-b-c','-',2) → b |
LEFT(s,n) | First n chars | LEFT('abcde',3) → abc |
RIGHT(s,n) | Last n chars | RIGHT('abcde',2) → de |
LPAD(s,n,c) | Pad from left | LPAD('5',3,'0') → 005 |
REVERSE(s) | Reverse the string | REVERSE('abc') → cba |
Remember the calculator app on your phone? It can do addition, square roots, rounding, etc. PostgreSQL has its own built-in calculator! You can do math right inside your SQL queries without pulling data into Python or Excel first. Need to round prices to 2 decimal places? Calculate a 15% tip? Find the square root of something? PostgreSQL has a function for that.
SELECT ROUND(3.14159, 2) AS rounded, -- 3.14 CEIL(3.2) AS ceiling, -- 4 (round UP) FLOOR(3.9) AS floored, -- 3 (round DOWN) ABS(-42) AS absolute, -- 42 MOD(17, 5) AS remainder, -- 2 (17 / 5 = 3 remainder 2) POWER(2, 10) AS two_to_ten, -- 1024 SQRT(144) AS square_root, -- 12 RANDOM() AS random_num; -- 0.7291... (random 0-1)
SELECT name, price, ROUND(price * 0.18, 2) AS gst, ROUND(price * 1.18, 2) AS total_with_gst FROM products ORDER BY price DESC LIMIT 5;
-- Pick 3 random products for a daily deal SELECT name, price FROM products ORDER BY RANDOM() LIMIT 3;
ROUND(2.555, 2) gives 2.56 (rounds up). TRUNC(2.559, 2) gives 2.55 (chops off, no rounding). Use TRUNC when you need to always round down, like calculating maximum discounts.
Imagine you run a pizza delivery business. You need to answer questions like: "How many orders did we get this week?" or "How many days has it been since this customer's last order?" or "What's the busiest hour of the day?" All of those require taking dates apart, comparing them, and doing math on them. That's what date/time functions do.
SELECT NOW() AS current_timestamp, -- 2026-03-19 14:30:15.123+05:30 CURRENT_DATE AS today, -- 2026-03-19 CURRENT_TIME AS time_now, -- 14:30:15.123+05:30 CURRENT_TIMESTAMP AS same_as_now; -- same as NOW()
SELECT EXTRACT(YEAR FROM NOW()) AS year, -- 2026 EXTRACT(MONTH FROM NOW()) AS month, -- 3 EXTRACT(DAY FROM NOW()) AS day, -- 19 EXTRACT(DOW FROM NOW()) AS weekday, -- 0=Sun, 1=Mon ... 6=Sat EXTRACT(HOUR FROM NOW()) AS hour; -- 14
-- Truncate to the beginning of the month SELECT DATE_TRUNC('month', NOW()) AS month_start; -- Result: 2026-03-01 00:00:00+05:30 -- Great for grouping orders by month SELECT DATE_TRUNC('month', order_date) AS month, COUNT(*) AS total_orders FROM orders GROUP BY DATE_TRUNC('month', order_date) ORDER BY month;
-- How old is this order? SELECT AGE(NOW(), '2024-06-15'::timestamp) AS order_age; -- Result: 1 year 9 mons 4 days 14:30:15.123 -- How long has each employee been with us? SELECT name, hire_date, AGE(NOW(), hire_date) AS tenure FROM employees ORDER BY hire_date;
SELECT TO_CHAR(NOW(), 'DD-Mon-YYYY') AS formatted1, -- 19-Mar-2026 TO_CHAR(NOW(), 'Day, DD Month YYYY') AS formatted2, -- Thursday , 19 March 2026 TO_CHAR(NOW(), 'HH12:MI AM') AS formatted3; -- 02:30 PM
SELECT NOW() + INTERVAL '7 days' AS next_week, NOW() - INTERVAL '3 months' AS three_months_ago, NOW() + INTERVAL '2 hours' AS in_two_hours; -- Practical: Find orders from the last 30 days SELECT * FROM orders WHERE order_date >= NOW() - INTERVAL '30 days';
Subscription services use date functions constantly: "When does this user's trial expire?" → signup_date + INTERVAL '14 days'. "Has the subscription lapsed?" → WHERE expiry_date < NOW(). "Show monthly revenue trends" → GROUP BY DATE_TRUNC('month', payment_date).
Imagine you’re ordering food online. If your favourite dish is available, you order that. If not, you pick your backup. If that’s gone too, you just get pizza. Conditional functions work the same way — they make smart choices based on the data they see.
SELECT name, COALESCE(phone, email, 'No contact info') AS contact FROM customers;
-- Avoid division by zero SELECT total_revenue / NULLIF(total_orders, 0) AS avg_order_value FROM monthly_stats;
SELECT GREATEST(10, 25, 5, 30) AS biggest, -- 30 LEAST(10, 25, 5, 30) AS smallest; -- 5 -- Practical: Ensure price is never below the minimum SELECT name, GREATEST(price, 9.99) AS final_price FROM products;
SELECT name, salary, CASE WHEN salary >= 90000 THEN 'Senior' WHEN salary >= 70000 THEN 'Mid-Level' ELSE 'Junior' END AS level FROM employees;
Think of a function like a recipe card. Instead of remembering all the steps to bake chocolate chip cookies every single time, you write them on a card once. From now on, you just say "follow the cookie recipe" and you get cookies! A PostgreSQL function is the same — you write the SQL logic once, give it a name, and then call it whenever you want. Less repetition, fewer mistakes.
-- A function to calculate 18% GST on any price CREATE FUNCTION calculate_gst(price NUMERIC) RETURNS NUMERIC LANGUAGE sql AS $$ SELECT ROUND(price * 0.18, 2); $$;
In PostgreSQL, the body of a function must be a string. Instead of wrapping it in single quotes (which gets messy when your SQL itself uses single quotes), PostgreSQL lets you use $$ as the string delimiter. Everything between $$ and $$ is the function body. Think of $$ as "super quotes."
SELECT name, price, calculate_gst(price) AS gst, price + calculate_gst(price) AS total FROM products;
CREATE FUNCTION format_inr(amount NUMERIC) RETURNS TEXT LANGUAGE sql AS $$ SELECT '₹' || TO_CHAR(amount, 'FM99,99,999.00'); $$; -- Usage SELECT format_inr(1250000.50); -- Result: ₹12,50,000.50
CREATE FUNCTION is_valid_email(email TEXT) RETURNS BOOLEAN LANGUAGE sql AS $$ SELECT email ~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$'; $$; -- Usage SELECT is_valid_email('john@example.com'); -- true SELECT is_valid_email('not-an-email'); -- false
LANGUAGE sql functions are simple: they contain one or more SQL statements and return the result of the last one. They're fast because PostgreSQL can inline them. Use LANGUAGE plpgsql when you need variables, IF/ELSE logic, loops, or error handling. We'll cover plpgsql next!
Regular SQL is like a vending machine — you press a button and get one thing. PL/pgSQL is like a robot chef — it can check ingredients (IF/THEN), repeat steps (LOOP), adjust the recipe based on what it finds, and even shout a warning if something goes wrong (RAISE NOTICE). It's a full programming language that lives inside PostgreSQL.
CREATE FUNCTION function_name(params) RETURNS return_type LANGUAGE plpgsql AS $$ DECLARE -- Variables go here my_variable INTEGER; BEGIN -- Logic goes here RETURN result; END; $$;
CREATE FUNCTION greet(person_name TEXT) RETURNS TEXT LANGUAGE plpgsql AS $$ DECLARE greeting TEXT; BEGIN greeting := 'Hello, ' || person_name || '! Welcome to PostgreSQL.'; RETURN greeting; END; $$; SELECT greet('Alice'); -- Result: Hello, Alice! Welcome to PostgreSQL.
CREATE FUNCTION classify_customer(total_spending NUMERIC) RETURNS TEXT LANGUAGE plpgsql AS $$ DECLARE tier TEXT; BEGIN IF total_spending >= 100000 THEN tier := '💎 Platinum'; ELSIF total_spending >= 50000 THEN tier := '🥇 Gold'; ELSIF total_spending >= 10000 THEN tier := '🥈 Silver'; ELSE tier := '🥉 Bronze'; END IF; RETURN tier; END; $$; -- Usage: Classify all customers SELECT name, total_spending, classify_customer(total_spending) AS tier FROM customers ORDER BY total_spending DESC;
CREATE FUNCTION countdown(start_num INTEGER) RETURNS VOID LANGUAGE plpgsql AS $$ DECLARE i INTEGER := start_num; BEGIN WHILE i > 0 LOOP RAISE NOTICE '% ...', i; i := i - 1; END LOOP; RAISE NOTICE 'Liftoff! 🚀'; END; $$; SELECT countdown(3); -- NOTICE: 3 ... -- NOTICE: 2 ... -- NOTICE: 1 ... -- NOTICE: Liftoff! 🚀
RAISE NOTICE is like console.log() in JavaScript or print() in Python. It's your debugging friend — it outputs messages to the PostgreSQL log so you can see what your function is doing step by step. There's also RAISE WARNING and RAISE EXCEPTION (which stops the function with an error).
CREATE FUNCTION list_expensive_products(min_price NUMERIC) RETURNS TEXT LANGUAGE plpgsql AS $$ DECLARE product RECORD; result TEXT := ''; BEGIN FOR product IN SELECT name, price FROM products WHERE price >= min_price ORDER BY price DESC LOOP result := result || product.name || ' ($' || product.price || '), '; END LOOP; RETURN RTRIM(result, ', '); END; $$;
A function is like a blender — you put ingredients in, you get a smoothie out (a return value). A procedure is like a dishwasher — you press start and it does a bunch of work (washing, rinsing, drying) but it doesn't "return" anything to your hand. The key difference: procedures can commit and rollback transactions in the middle of their work. Functions cannot.
CREATE PROCEDURE transfer_funds( sender_id INTEGER, receiver_id INTEGER, amount NUMERIC ) LANGUAGE plpgsql AS $$ BEGIN -- Deduct from sender UPDATE accounts SET balance = balance - amount WHERE id = sender_id; -- Add to receiver UPDATE accounts SET balance = balance + amount WHERE id = receiver_id; COMMIT; END; $$;
-- Transfer $500 from account 1 to account 2 CALL transfer_funds(1, 2, 500.00);
| Feature | Function | Procedure |
|---|---|---|
| Returns a value? | Yes (always) | No (use OUT params) |
| Can be used in SELECT? | Yes | No |
| Can COMMIT/ROLLBACK? | No | Yes |
| How to call? | SELECT func() | CALL proc() |
| Introduced in PG | Always existed | PostgreSQL 11+ |
Use procedures for multi-step operations that need transaction control: bank transfers, batch updates, end-of-day processing, data migration scripts. Use functions for calculations, transformations, and anything you want to call inside a SELECT statement.
Imagine a doorbell. Nobody has to stand outside and watch for visitors — when someone presses the button (an event), the bell rings automatically (an action). A trigger is a database doorbell: when something happens to a table (INSERT, UPDATE, DELETE), PostgreSQL automatically runs a function you defined. No human intervention needed.
| Timing | Meaning | Use Case |
|---|---|---|
| BEFORE | Runs before the change is saved | Validate/modify data before it's written |
| AFTER | Runs after the change is saved | Log the change, send notifications |
| INSTEAD OF | Replaces the action (views only) | Make views updatable |
| Variable | What It Holds |
|---|---|
NEW | The new row being inserted or updated |
OLD | The old row before update or deletion |
TG_OP | The operation: 'INSERT', 'UPDATE', or 'DELETE' |
TG_TABLE_NAME | Name of the table that fired the trigger |
-- Step 1: Create the trigger function CREATE FUNCTION set_updated_at() RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN NEW.updated_at := NOW(); RETURN NEW; END; $$; -- Step 2: Attach the trigger to a table CREATE TRIGGER trg_employees_updated_at BEFORE UPDATE ON employees FOR EACH ROW EXECUTE FUNCTION set_updated_at();
Now, every time you UPDATE any employee row, the updated_at column is automatically set to the current time. You never have to remember to do it manually!
-- First, create an audit log table CREATE TABLE audit_log ( id SERIAL PRIMARY KEY, table_name TEXT, operation TEXT, old_data JSONB, new_data JSONB, changed_at TIMESTAMP DEFAULT NOW(), changed_by TEXT DEFAULT CURRENT_USER ); -- Create the audit trigger function CREATE FUNCTION log_changes() RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF TG_OP = 'DELETE' THEN INSERT INTO audit_log (table_name, operation, old_data) VALUES (TG_TABLE_NAME, 'DELETE', to_jsonb(OLD)); RETURN OLD; ELSIF TG_OP = 'UPDATE' THEN INSERT INTO audit_log (table_name, operation, old_data, new_data) VALUES (TG_TABLE_NAME, 'UPDATE', to_jsonb(OLD), to_jsonb(NEW)); RETURN NEW; ELSIF TG_OP = 'INSERT' THEN INSERT INTO audit_log (table_name, operation, new_data) VALUES (TG_TABLE_NAME, 'INSERT', to_jsonb(NEW)); RETURN NEW; END IF; END; $$; -- Attach it to the employees table CREATE TRIGGER trg_employees_audit AFTER INSERT OR UPDATE OR DELETE ON employees FOR EACH ROW EXECUTE FUNCTION log_changes();
Audit triggers are used by banks, hospitals, and any regulated industry. If a nurse changes a patient's medication record, the audit log captures exactly what changed, when, and by whom. This is often a legal requirement. PostgreSQL triggers make this automatic and tamper-proof.
Triggers run for every single row affected by the operation. If you UPDATE 1 million rows, the trigger function executes 1 million times. Keep trigger functions fast and lightweight. For heavy work, consider using AFTER triggers with deferred processing or a message queue.
Imagine you look out your bedroom window. You see a beautiful garden, but you don't own the garden — it belongs to the park next door. The window gives you a view of something that exists elsewhere. A database view is the same: it's a saved query that looks and behaves like a table, but it doesn't store any data itself. Every time you query the view, it runs the underlying query and shows you fresh results.
-- A view that shows only Engineering employees CREATE VIEW engineering_team AS SELECT id, name, salary, hire_date FROM employees WHERE department = 'Engineering'; -- Now query it like a regular table! SELECT * FROM engineering_team;
-- A view that joins orders with customers and calculates totals CREATE VIEW order_summary AS SELECT c.name AS customer_name, COUNT(o.id) AS total_orders, SUM(o.amount) AS total_spent, MAX(o.order_date) AS last_order FROM customers c JOIN orders o ON c.id = o.customer_id GROUP BY c.name; -- Now anyone can run this simple query instead of the complex one SELECT * FROM order_summary WHERE total_spent > 10000;
Simple views (no JOINs, no GROUP BY, no DISTINCT) are automatically updatable:
-- You can INSERT/UPDATE/DELETE through the view! UPDATE engineering_team SET salary = 95000 WHERE name = 'Alice Johnson'; -- This actually updates the employees table
CREATE VIEW engineering_team AS SELECT id, name, department, salary FROM employees WHERE department = 'Engineering' WITH CHECK OPTION; -- This will FAIL because 'Sales' doesn't match the view's WHERE INSERT INTO engineering_team (name, department, salary) VALUES ('Sneaky Pete', 'Sales', 50000); -- ERROR: new row violates check option for view "engineering_team"
Views are great for access control. You can give a junior analyst access to a view that only shows non-sensitive columns (no salary, no SSN) while the underlying table contains everything. The analyst doesn't even need to know the full table exists!
-- Modify an existing view without dropping it first CREATE OR REPLACE VIEW engineering_team AS SELECT id, name, salary, hire_date, AGE(NOW(), hire_date) AS tenure FROM employees WHERE department = 'Engineering';
DROP VIEW IF EXISTS engineering_team;Write a query that takes the email 'john.doe@company.com' and extracts just the username (before the @), converts it to title case, and replaces the dot with a space. Expected result: John Doe.
SELECT INITCAP( REPLACE( SPLIT_PART('john.doe@company.com', '@', 1), '.', ' ' ) ) AS full_name;
Create a function calc_total(price NUMERIC, tax_rate NUMERIC DEFAULT 0.18) that returns the price including tax, rounded to 2 decimal places.
CREATE FUNCTION calc_total(price NUMERIC, tax_rate NUMERIC DEFAULT 0.18) RETURNS NUMERIC LANGUAGE sql AS $$ SELECT ROUND(price * (1 + tax_rate), 2); $$;
Create a trigger on the products table that automatically sets updated_at to NOW() whenever a product's price changes.
CREATE FUNCTION update_product_timestamp() RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF OLD.price <> NEW.price THEN NEW.updated_at := NOW(); END IF; RETURN NEW; END; $$; CREATE TRIGGER trg_product_price_change BEFORE UPDATE ON products FOR EACH ROW EXECUTE FUNCTION update_product_timestamp();
SPLIT_PART('a-b-c', '-', 2) return?$$ (dollar-quoting) do in a function definition?NEW represent?WITH CHECK OPTION do on a view?You’ve leveled up from a SQL user to a SQL programmer. Here’s what you accomplished:
| Topic | Key Takeaway |
|---|---|
| String Functions | UPPER, LOWER, TRIM, REPLACE, SPLIT_PART, LPAD — clean and transform text |
| Math Functions | ROUND, CEIL, FLOOR, POWER, RANDOM — do calculations in the database |
| Date/Time Functions | NOW, EXTRACT, DATE_TRUNC, AGE, intervals — master time-based queries |
| Conditional Functions | COALESCE, NULLIF, GREATEST, LEAST, CASE — smart defaults and choices |
| User-Defined Functions | CREATE FUNCTION with SQL and PL/pgSQL — reusable logic |
| Procedures | CREATE PROCEDURE + CALL — multi-step operations with transaction control |
| Triggers | BEFORE/AFTER triggers — automatic auditing and timestamp updates |
| Views | Virtual tables that simplify complex queries and enhance security |
What happens when two people try to buy the last item in stock at the exact same time? How does a bank ensure money doesn’t vanish during a transfer? In the next module, you’ll learn about transactions, ACID properties, isolation levels, and MVCC — the technology that keeps your data safe when thousands of users hit the database at once. It’s one of the most important topics in all of database engineering!