User-Defined Functions in SQL: CREATE FUNCTION Guide
Chat2DB TeamUser-defined functions (UDFs) let you package a calculation or a reusable query fragment behind a name, then call it from any SELECT, WHERE, or JOIN like a built-in function. Used well, they remove duplicated expressions from dozens of queries; used carelessly, they are one of the most common causes of mysteriously slow queries, because the optimizer often cannot see inside them.
This guide covers what user defined functions in SQL are, the CREATE FUNCTION syntax in MySQL, PostgreSQL, and SQL Server, practical examples you can run, the performance traps unique to each engine, and when a view or computed column is the better tool.
What Is a User-Defined Function?
A UDF is a named routine stored in the database that accepts parameters and returns either:
- A scalar value — one value per call (a formatted string, a computed tax amount). Callable anywhere an expression is allowed.
- A table — a result set you can
SELECTfrom or join against. These are table-valued functions (TVFs), supported natively by PostgreSQL and SQL Server; MySQL does not support SQL-defined table functions (only scalar stored functions, plus C/C++ loadable functions at the server level).
Unlike stored procedures, functions are (by design) side-effect-light and composable inside queries: you call them in expressions, not with CALL/EXEC.
Sample Data
The examples share this schema. Adjust types per engine as noted; a cross-database client such as Chat2DB (opens in a new tab) is convenient here because you can run the same script against MySQL, PostgreSQL, and SQL Server connections side by side.
CREATE TABLE orders (
id INT PRIMARY KEY,
customer VARCHAR(50) NOT NULL,
country CHAR(2) NOT NULL,
amount DECIMAL(10,2) NOT NULL,
order_date DATE NOT NULL
);
INSERT INTO orders (id, customer, country, amount, order_date) VALUES
(1, 'Acme GmbH', 'DE', 100.00, '2026-07-01'),
(2, 'Acme GmbH', 'DE', 250.00, '2026-07-15'),
(3, 'Bolt Ltd', 'GB', 80.00, '2026-07-20'),
(4, 'Cato Inc', 'US', 500.00, '2026-08-02'),
(5, 'Cato Inc', 'US', 40.00, '2026-08-10');MySQL: CREATE FUNCTION
MySQL stored functions are always scalar. The minimal syntax:
DELIMITER $$
CREATE FUNCTION vat_amount(net DECIMAL(10,2), country CHAR(2))
RETURNS DECIMAL(10,2)
DETERMINISTIC
BEGIN
DECLARE rate DECIMAL(4,3);
SET rate = CASE country
WHEN 'DE' THEN 0.190
WHEN 'GB' THEN 0.200
WHEN 'US' THEN 0.000
ELSE 0.150
END;
RETURN ROUND(net * rate, 2);
END$$
DELIMITER ;Usage and expected output:
SELECT id, country, amount, vat_amount(amount, country) AS vat
FROM orders;id | country | amount | vat
1 | DE | 100.00 | 19.00
2 | DE | 250.00 | 47.50
3 | GB | 80.00 | 16.00
4 | US | 500.00 | 0.00
5 | US | 40.00 | 0.00The characteristic keywords matter more than they look:
DETERMINISTICdeclares that the same inputs always produce the same output. MySQL does not verify this — you are making a promise. It affects binary logging: withlog_binenabled andlog_bin_trust_function_creators = 0, MySQL refuses to create a function unless it is declaredDETERMINISTIC,NO SQL, orREADS SQL DATA, because non-deterministic functions can break statement-based replication.READS SQL DATA/NO SQL/MODIFIES SQL DATAdescribe whether the body touches tables. A function that queries a rate table would beREADS SQL DATAand typically notDETERMINISTIC(its result changes when the table changes).
A lookup-based variant:
DELIMITER $$
CREATE FUNCTION customer_lifetime(cust VARCHAR(50))
RETURNS DECIMAL(12,2)
READS SQL DATA
BEGIN
RETURN (SELECT COALESCE(SUM(amount), 0) FROM orders WHERE customer = cust);
END$$
DELIMITER ;Be careful calling functions like this in large SELECTs: MySQL executes the body once per row, and the inner query is not merged into the outer plan.
PostgreSQL: LANGUAGE SQL and plpgsql
PostgreSQL supports functions in several languages; the two everyday choices are SQL (a body that is just a query) and plpgsql (procedural logic).
A scalar SQL function for display formatting:
CREATE FUNCTION format_money(v NUMERIC, cur TEXT)
RETURNS TEXT
LANGUAGE sql
IMMUTABLE
RETURN cur || ' ' || to_char(v, 'FM999,999,990.00');SELECT format_money(amount, 'EUR') FROM orders WHERE id = 2;
-- => EUR 250.00(The compact RETURN expression body requires PostgreSQL 14+; on older versions use AS $$ SELECT ... $$.)
Volatility labels — IMMUTABLE, STABLE, VOLATILE (the default) — are PostgreSQL's version of DETERMINISTIC, and they directly affect optimization: IMMUTABLE functions can be folded to constants and used in expression indexes; VOLATILE functions are re-executed for every row and block many optimizations. Mislabeling a function IMMUTABLE when it reads tables produces wrong cached results, so label honestly.
RETURNS TABLE
Table-valued functions shine as reusable, parameterized filters:
CREATE FUNCTION orders_in_range(d1 DATE, d2 DATE)
RETURNS TABLE (id INT, customer VARCHAR, amount NUMERIC)
LANGUAGE sql
STABLE
AS $$
SELECT id, customer, amount
FROM orders
WHERE order_date BETWEEN d1 AND d2
$$;
SELECT * FROM orders_in_range('2026-07-01', '2026-07-31');id | customer | amount
1 | Acme GmbH | 100.00
2 | Acme GmbH | 250.00
3 | Bolt Ltd | 80.00A key PostgreSQL behavior: simple LANGUAGE sql functions can be inlined into the calling query, letting the planner optimize the combined query as if you had written the SELECT by hand — indexes on order_date are used normally. Inlining requires, among other things, that the function is LANGUAGE sql, not VOLATILE when used in scalar context (for TVFs the rules differ slightly), has no SECURITY DEFINER, and its body is a single SELECT. plpgsql functions are never inlined — they run as a black box per call. When performance matters, prefer LANGUAGE sql for query-shaped functions and reserve plpgsql for genuine procedural logic (loops, exception handling):
CREATE FUNCTION safe_div(a NUMERIC, b NUMERIC)
RETURNS NUMERIC
LANGUAGE plpgsql
IMMUTABLE
AS $$
BEGIN
IF b = 0 THEN
RETURN NULL;
END IF;
RETURN a / b;
END;
$$;SQL Server: Scalar and Inline Table-Valued Functions
A scalar function in T-SQL:
CREATE FUNCTION dbo.VatAmount (@net DECIMAL(10,2), @country CHAR(2))
RETURNS DECIMAL(10,2)
AS
BEGIN
RETURN ROUND(@net * CASE @country
WHEN 'DE' THEN 0.19
WHEN 'GB' THEN 0.20
WHEN 'US' THEN 0.00
ELSE 0.15 END, 2);
END;And the far more optimizer-friendly inline table-valued function (iTVF) — a single RETURN (SELECT ...) with no BEGIN/END:
CREATE FUNCTION dbo.OrdersInRange (@d1 DATE, @d2 DATE)
RETURNS TABLE
AS
RETURN (
SELECT id, customer, amount
FROM dbo.orders
WHERE order_date BETWEEN @d1 AND @d2
);SELECT o.customer, SUM(o.amount) AS total
FROM dbo.OrdersInRange('2026-07-01', '2026-07-31') AS o
GROUP BY o.customer;An iTVF is expanded into the calling query like a parameterized view, so it costs essentially nothing over inline SQL. By contrast, multi-statement TVFs (RETURNS @t TABLE (...) with a body that fills the variable) materialize their result with poor cardinality estimates and should be a last resort.
Performance Caveats
The single biggest UDF pitfall is scalar UDFs in SQL Server. Historically, a scalar UDF in a query forced row-by-row execution, prevented parallelism for the whole plan, and hid its cost from execution plans. A query over a million rows calling dbo.VatAmount per row could be orders of magnitude slower than the equivalent inline CASE expression. SQL Server 2019 introduced scalar UDF inlining (automatic translation of eligible T-SQL scalar UDFs into relational expressions), which fixes many cases — but eligibility rules are long (no GETDATE() in some patterns, no table variables, etc.), and inlining can be disabled per function. Check sys.sql_modules.is_inlineable before relying on it.
Engine-by-engine summary:
- SQL Server: prefer iTVFs and inline expressions; treat multi-statement TVFs and non-inlineable scalar UDFs as performance hazards.
- PostgreSQL: prefer
LANGUAGE sqlwith correct volatility so inlining applies;plpgsqlin hot paths means one function-call overhead (and often one plan) per row. - MySQL: every stored function call interprets the body; there is no inlining at all. Avoid calling functions that contain queries (
READS SQL DATA) inside large scans — rewrite as aJOIN. Also note that a WHERE clause likeWHERE vat_amount(amount, country) > 50is not sargable in any engine: it defeats index use on the wrapped column.
When to Prefer Views or Computed Columns
A UDF is not always the right abstraction:
- Views are better for reusable row sets without parameters. The optimizer merges views into queries reliably in all three engines, and permissions, indexing (indexed views in SQL Server, materialized views in PostgreSQL) and tooling support are stronger. If your "function" takes no arguments, make it a view.
- Computed / generated columns are better for per-row derived values you filter or sort on. MySQL generated columns (
vat DECIMAL(10,2) AS (ROUND(amount * 0.19, 2)) STORED) and SQL Server computed columns can be indexed, making the derived value sargable — something a scalar UDF call inWHEREnever is. PostgreSQL offersGENERATED ALWAYS AS (...) STOREDplus expression indexes onIMMUTABLEfunctions. - Keep UDFs for genuinely parameterized logic (tax by country and date), cross-query business rules that must have one authoritative implementation, and table-valued filters that inline well.
Dropping and Altering Functions
Removing a function is uniform:
DROP FUNCTION IF EXISTS vat_amount; -- MySQL
DROP FUNCTION IF EXISTS format_money(NUMERIC, TEXT); -- PostgreSQL (signature needed if overloaded)
DROP FUNCTION IF EXISTS dbo.VatAmount; -- SQL ServerChanging a body differs:
- MySQL:
ALTER FUNCTIONcan only change characteristics (COMMENT,SQL SECURITY, data-access clause) — not the body or parameters. To change logic,DROPandCREATEagain (grants on the function are lost and must be re-issued). - PostgreSQL:
CREATE OR REPLACE FUNCTIONswaps the body in place, preserving grants and dependent objects, as long as the parameter types and return type are unchanged. Since PostgreSQL supports overloading, the signature identifies the function. - SQL Server: use
CREATE OR ALTER FUNCTION(SQL Server 2016 SP1+) to update in place while keeping permissions.
Watch dependencies before dropping: PostgreSQL blocks DROP if an index or generated column depends on the function (CASCADE drops those too — usually not what you want), while MySQL and SQL Server will happily let you break callers at runtime.
Conclusion
User-defined functions in SQL are a contract between readability and the optimizer. Scalar UDFs make code cleaner but can hide per-row costs; table-valued functions — PostgreSQL inlined SQL functions and SQL Server iTVFs — give you reuse and good plans when written as single, simple queries. Declare determinism and data access honestly (DETERMINISTIC/READS SQL DATA in MySQL, volatility labels in PostgreSQL), reach for views or generated columns when the logic is parameter-free or needs indexing, and always benchmark a UDF-based query against its inlined equivalent before shipping it on a hot path.
