Пользовательские функции в MS SQL Server: скалярные, табличные и отличия от процедур

Пользовательские функции в MS SQL Server: скалярные, табличные и отличия от процедур Полезное

Пользовательская функция (UDF, user-defined function) в MS SQL Server — это объект базы, созданный командой CREATE FUNCTION, который принимает параметры и возвращает либо одно значение (скалярная функция), либо набор строк (табличная функция). Функцию вызывают внутри запроса — в SELECT, WHERE, JOIN, APPLY, — и в этом ее главное отличие от хранимой процедуры, которую запускают отдельной командой EXEC.

Коротко о трех видах: скалярная функция возвращает одно значение и вызывается в выражении, встраиваемая табличная (inline TVF) — результат одного SELECT, многооператорная табличная (MSTVF) — таблицу, которую заполняет код из нескольких инструкций.

Ниже — три вида UDF на одном сквозном примере, типичные ошибки с реальными текстами сервера, права, SCHEMABINDING и то, как функции влияют на скорость запросов. Примеры проверены 23.09.2026 на движке Azure SQL Edge 15.0 (ядро линейки SQL Server 2019, уровень совместимости 150).

Словарь: что с чем не путать

Термин Что возвращает Где вызывается
Встроенная функция (LEN, ROUND, SUM) значение в выражениях запроса
Скалярная UDF одно значение в выражениях, всегда с именем схемы: dbo.fn(...)
Встраиваемая табличная (inline TVF) результат одного SELECT во FROM, JOIN, APPLY
Многооператорная табличная (MSTVF) таблицу-переменную, заполненную кодом во FROM, JOIN, APPLY
Хранимая процедура наборы строк, код возврата, OUTPUT-параметры только через EXEC

Встроенные функции (строковые, даты, математические, агрегатные, оконные с OVER) дает сам сервер, их не создают. Эта статья — про функции, которые пишете вы.

Перед запуском: тестовая база и права

Все примеры меняют схему dbo (создают таблицу, функции, столбцы), поэтому запускайте их в отдельной тестовой базе, а не в рабочей. Роль sysadmin или db_owner для этого не нужна: создателю функции достаточно права CREATE FUNCTION в базе и ALTER на схему, в которой создается функция, а для таблицы из примера — еще CREATE TABLE. Без ALTER на схему первый же CREATE FUNCTION упадет с ошибкой 2760. Подробности и границы этих прав — в разделе «Права на создание и вызов» ниже.

Минимальный полный пример: три вида функций

Скрипт создает таблицу заказов и по одной функции каждого вида.

CREATE TABLE dbo.Orders (
    OrderId    int IDENTITY PRIMARY KEY,
    Customer   nvarchar(50)  NOT NULL,
    Amount     decimal(10,2) NOT NULL,
    OrderDate  date          NOT NULL
);
INSERT INTO dbo.Orders (Customer, Amount, OrderDate) VALUES
 (N'Анна', 1200.00, '2026-09-01'),
 (N'Анна',  300.00, '2026-09-15'),
 (N'Борис', 5000.00, '2026-09-10');
GO
-- 1. Скалярная: одно значение на вызов
CREATE OR ALTER FUNCTION dbo.fn_WithVat (@amount decimal(10,2), @rate decimal(4,2) = 0.20)
RETURNS decimal(12,2)
AS
BEGIN
    RETURN ROUND(@amount * (1 + @rate), 2);
END;
GO
-- 2. Встраиваемая табличная: тело = один SELECT
CREATE OR ALTER FUNCTION dbo.fn_OrdersOf (@customer nvarchar(50))
RETURNS TABLE
AS
RETURN (
    SELECT OrderId, Amount, OrderDate
    FROM dbo.Orders
    WHERE Customer = @customer
);
GO
-- 3. Многооператорная табличная: таблица-переменная + несколько инструкций
CREATE OR ALTER FUNCTION dbo.fn_CustomerStats ()
RETURNS @result TABLE (Customer nvarchar(50), Orders int, Total decimal(12,2), Segment nvarchar(10))
AS
BEGIN
    INSERT INTO @result (Customer, Orders, Total)
    SELECT Customer, COUNT(*), SUM(Amount) FROM dbo.Orders GROUP BY Customer;

    UPDATE @result SET Segment = CASE WHEN Total >= 3000 THEN N'VIP' ELSE N'обычный' END;
    RETURN;
END;
GO
SELECT OrderId, Amount, dbo.fn_WithVat(Amount, DEFAULT) AS WithVat FROM dbo.Orders;
SELECT * FROM dbo.fn_OrdersOf(N'Анна');
SELECT * FROM dbo.fn_CustomerStats() ORDER BY Customer;

Результат трех запросов: к каждой сумме добавлен НДС 20%, для Анны вернулись два ее заказа, статистика разложила клиентов по сегментам.

OrderId | Amount  | WithVat
1       | 1200.00 | 1440.00
2       | 300.00  | 360.00
3       | 5000.00 | 6000.00

OrderId | Amount  | OrderDate
1       | 1200.00 | 2026-09-01
2       | 300.00  | 2026-09-15

Customer | Orders | Total   | Segment
Анна     | 2      | 1500.00 | обычный
Борис    | 1      | 5000.00 | VIP

Разбор по видам:

  • Скалярная — тело в BEGIN ... END, обязателен RETURN со значением типа из RETURNS. Внутри можно объявлять переменные, писать IF и WHILE.
  • Inline TVFRETURNS TABLE без списка столбцов, тело без BEGIN/END, только RETURN (SELECT ...). По сути это представление с параметрами: оптимизатор подставляет запрос внутрь внешнего.
  • MSTVF — в RETURNS объявлена таблица-переменная со столбцами, код заполняет ее, RETURN без аргумента отдает результат.

CREATE OR ALTER работает с SQL Server 2016 SP1; в более старых версиях используют пару DROP FUNCTION и CREATE FUNCTION.

Как вызывать: схема, DEFAULT и APPLY

В запросе скалярную функцию вызывают только с именем схемы. Без него сервер ищет встроенную функцию:

SELECT fn_WithVat(100);
-- ошибка 195: 'fn_WithVat' is not a recognized function name.
SELECT dbo.fn_WithVat(100);
-- ошибка 313: An insufficient number of arguments were supplied for the procedure or function dbo.fn_WithVat.
SELECT dbo.fn_WithVat(100, DEFAULT);   -- 120.00

Вторая ловушка видна по ошибке 313: значение по умолчанию у параметра скалярной функции не подставляется само, аргумент нужно пропустить явно словом DEFAULT.

Табличную функцию для каждой строки внешней таблицы вызывают через APPLY. CROSS APPLY ведет себя как INNER JOIN (строки без результата отбрасываются), OUTER APPLY — как LEFT JOIN (остаются с NULL):

SELECT c.Name, x.OrderId, x.Amount
FROM (VALUES (N'Анна'), (N'Вера')) AS c(Name)
OUTER APPLY dbo.fn_OrdersOf(c.Name) AS x;
Name | OrderId | Amount
Анна | 1       | 1200.00
Анна | 2       | 300.00
Вера | NULL    | NULL

С CROSS APPLY строки Веры в результате нет.

Чем функция отличается от хранимой процедуры

Критерий Функция (UDF) Хранимая процедура
Вызов внутри запроса EXEC dbo.usp_...
Результат одно значение или одна таблица несколько наборов строк, OUTPUT-параметры
Изменение постоянных таблиц запрещено разрешено
TRY...CATCH, динамический SQL, временные #-таблицы запрещены разрешены
Вызов процедур нет (кроме части расширенных) да
Транзакции не управляет BEGIN TRAN, COMMIT, ROLLBACK

Правило выбора: нужна вычисляемая величина или параметризованный набор строк для запроса — функция; нужно что-то изменить в данных — процедура.

Попытка изменить таблицу внутри функции не пройдет уже на этапе создания:

CREATE OR ALTER FUNCTION dbo.fn_Bad (@c nvarchar(50))
RETURNS int
AS
BEGIN
    INSERT INTO dbo.Orders (Customer, Amount, OrderDate) VALUES (@c, 0, '2026-09-23');
    RETURN 1;
END;
-- ошибка 443: Invalid use of a side-effecting operator 'INSERT' within a function.

Та же ошибка 443 возникает для BEGIN TRY, EXEC('...') и NEWID(), а обращение к #tmp дает ошибку 2772. Исправление — перенести изменение данных в процедуру, а в функции оставить вычисление. Таблицы-переменные (DECLARE @t TABLE) внутри функции менять можно, на этом построена MSTVF.

SCHEMABINDING и детерминированность

Опция WITH SCHEMABINDING привязывает функцию к объектам, на которые она ссылается: их нельзя изменить так, чтобы функция сломалась.

CREATE OR ALTER FUNCTION dbo.fn_OrdersOf (@customer nvarchar(50))
RETURNS TABLE
WITH SCHEMABINDING
AS
RETURN (SELECT OrderId, Amount, OrderDate FROM dbo.Orders WHERE Customer = @customer);
GO
ALTER TABLE dbo.Orders DROP COLUMN OrderDate;
-- ошибка 5074: The object 'fn_OrdersOf' is dependent on column 'OrderDate'.

Условия привязки: имена объектов двухчастные (dbo.Orders, а не Orders — иначе ошибка 4512), без SELECT * (ошибка 1054), объекты в той же базе, вызываемые функции и представления тоже с SCHEMABINDING, у автора есть право REFERENCES на эти объекты.

Для скалярной функции SCHEMABINDING влияет и на признак детерминированности. Та же dbo.fn_WithVat без опции дает OBJECTPROPERTY(OBJECT_ID('dbo.fn_WithVat'), 'IsDeterministic') = 0, с опцией — 1. Только детерминированную функцию можно использовать в сохраняемом вычисляемом столбце:

CREATE OR ALTER FUNCTION dbo.fn_WithVat (@amount decimal(10,2), @rate decimal(4,2) = 0.20)
RETURNS decimal(12,2)
WITH SCHEMABINDING
AS
BEGIN
    RETURN ROUND(@amount * (1 + @rate), 2);
END;
GO
ALTER TABLE dbo.Orders ADD AmountWithVat AS dbo.fn_WithVat(Amount, 0.20) PERSISTED;

После этого изменить саму функцию нельзя: ALTER вернет ошибку 3729 «Cannot ALTER ‘dbo.fn_WithVat’ because it is being referenced by object ‘Orders’». В этом примере на тестовой базе порядок правки такой: удалить столбец (это снимает зависимость), изменить функцию, создать столбец заново. На рабочей базе так делать напрямую не стоит: сначала найдите все объекты, зависящие от столбца (индексы, ограничения, представления), и составьте миграцию с планом отката. Учтите, что при создании PERSISTED-столбца сервер пересчитывает и записывает значение для каждой строки, на большой таблице это долгая операция с блокировкой, ее планируют в окно обслуживания.

Производительность: где функции тормозят

Упрощенная модель: inline TVF обычно ведет себя как представление, а скалярная функция и MSTVF могут стать «черным ящиком» для оптимизатора. Точное поведение зависит от версии и уровня совместимости базы, поэтому решения подтверждайте планом и замером на своих данных.

Вид Как видит оптимизатор Риск
Inline TVF подставляет SELECT в запрос, использует индексы и статистику минимальный
Скалярная UDF до SQL Server 2019 вызывает для каждой строки медленно на больших выборках, мешает параллелизму
Скалярная UDF с SQL Server 2019 (уровень 150 и выше) может встроить тело в запрос (scalar UDF inlining) встраивание не гарантировано: зависит от тела функции, места вызова, настроек базы и запроса, накопительного обновления
MSTVF фиксированная оценка числа строк (100 с SQL Server 2014, раньше 1); с SQL Server 2017 (уровень 140) в запросах только на чтение ее уточняет interleaved execution неудачный план соединений, особенно в INSERT/UPDATE/DELETE

Допускает ли определение функции встраивание, видно в sys.sql_modules. Для примера создадим функцию, зависящую от текущей даты:

CREATE OR ALTER FUNCTION dbo.fn_DaysAgo (@d date)
RETURNS int
AS
BEGIN
    RETURN DATEDIFF(day, @d, GETDATE());
END;
GO
SELECT OBJECT_NAME(object_id) AS name, is_inlineable
FROM sys.sql_modules
WHERE object_id IN (OBJECT_ID('dbo.fn_WithVat'), OBJECT_ID('dbo.fn_DaysAgo'));
name       | is_inlineable
fn_WithVat | 1
fn_DaysAgo | 0

У dbo.fn_DaysAgo зависимость от текущего времени (GETDATE()) — одна из причин, по которой функция не встраивается. Обратное не гарантировано: is_inlineable = 1 значит только, что тело функции подходит, а решение сервер принимает для каждого запроса. Например, в вычисляемом столбце, ORDER BY или GROUP BY функция не встраивается, поэтому dbo.fn_WithVat в столбце AmountWithVat по-прежнему вызывается построчно. Поэтому у производительности скалярной функции два независимых вопроса. Первый — пригодна ли функция к встраиванию: is_inlineable в sys.sql_modules и уровень совместимости базы 150 и выше. Второй — встроилась ли она в конкретном запросе: это видно только по фактическому плану, где при встраивании нет узла UserDefinedFunction. На ответ влияет и настройка базы TSQL_SCALAR_UDF_INLINING (ALTER DATABASE SCOPED CONFIGURATION): в прогоне на Azure SQL Edge 15.0 при ON план запроса SELECT dbo.fn_WithVat(Amount, DEFAULT) FROM dbo.Orders не содержал UserDefinedFunction, а после переключения в OFF узел появился, хотя is_inlineable так и остался равен 1. Перечень условий встраивания Microsoft уточняла в накопительных обновлениях SQL Server 2019, поэтому на другой сборке результат для той же функции может отличаться. Отключить встраивание для конкретной функции можно опцией WITH INLINE = OFF, для запроса — подсказкой OPTION (USE HINT('DISABLE_TSQL_SCALAR_UDF_INLINING')).

Практический порядок: сначала пробуйте inline TVF, скалярную функцию на больших таблицах проверяйте по is_inlineable, настройке базы и фактическому плану, MSTVF берите, когда логику не выразить одним SELECT.

Права на создание и вызов

Чтобы создать функцию, нужно право CREATE FUNCTION в базе и ALTER на схему. Одного GRANT CREATE FUNCTION мало — сервер ответит ошибкой 2760 «The specified schema name «dbo» either does not exist or you do not have permission to use it».

Но ALTER на схему — широкое право. Пользователь с ALTER ON SCHEMA::dbo может удалить любую таблицу схемы dbo и переписать чужие функции в ней: в проверке такой пользователь выполнил DROP TABLE и убрал фильтр из dbo.fn_OrdersOf, которой пользуются другие. Кроме того, документация Microsoft предупреждает, что через цепочку владения он может добраться до данных, доступ к которым ему явно запрещен. Поэтому для кода разработчика удобнее выделить отдельную схему, а в dbo выкатывать код контролируемым деплоем — через ревью и отдельную учетную запись:

-- CREATE SCHEMA должна быть единственной командой пакета, поэтому после нее GO
CREATE SCHEMA dev AUTHORIZATION dev1;   -- схема принадлежит пользователю dev1
GO
GRANT CREATE FUNCTION TO dev1;
-- в этом примере dev1 создает и вызывает dev.fn_..., но таблицы dbo удалить не может:
-- DROP TABLE dbo.Orders;
-- ошибка 3701: Cannot drop the table 'Orders', because it does not exist or you do not have permission.

Функции из схемы dev читают таблицы dbo только в пределах прав самого dev1: у схем разные владельцы, цепочка владения рвется, и запрет DENY SELECT ON dbo.Orders продолжает действовать (ошибка 229).

Границы этого приема стоит понимать точно. Собственная схема — не песочница: владелец может создавать, менять и удалять любые объекты в ней, а доступ к чужим данным зависит не от схемы, а от выданных прав на таблицы, от того, кому принадлежат схемы (при общем владельце цепочка владения не рвется и проверки прав на таблицы пропускаются), и от контекста исполнения EXECUTE AS в функциях и процедурах. Поэтому, выделив схему, отдельно проверьте права пользователя на таблицы, владельцев схем и объекты с EXECUTE AS.

Для вызова права различаются по виду: скалярной функции нужно EXECUTE, табличной — SELECT. Пользователь с одним GRANT EXECUTE ON dbo.fn_WithVat получит ошибку 229 на SELECT * FROM dbo.fn_OrdersOf(...), пока не выдать GRANT SELECT ON dbo.fn_OrdersOf.

Если не получилось

Симптом Причина Что сделать
ошибка 195 «is not a recognized function name» скалярная функция вызвана без схемы писать dbo.fn_...
ошибка 313 «insufficient number of arguments» пропущен параметр со значением по умолчанию передать DEFAULT
ошибка 4514 «column name is not specified» в inline TVF столбец-выражение без имени дать псевдоним: SUM(Amount) AS Total
ошибка 443 «side-effecting operator» изменение таблицы, TRY, EXEC в функции вынести в процедуру
ошибка 557 при вызове внутри функции EXEC процедуры создание проходит, падает выполнение — переписать без процедуры
запрос с функцией резко замедлился скалярная UDF по строкам или MSTVF с плохой оценкой проверить is_inlineable, план, заменить на inline TVF

Выводы

  • UDF в MS SQL Server бывают трех видов: скалярные, встраиваемые табличные и многооператорные табличные; все создаются через CREATE FUNCTION.
  • Функцию вызывают внутри запроса, процедуру — через EXEC; изменять постоянные таблицы, использовать TRY...CATCH, динамический SQL и #-таблицы в функции нельзя.
  • Скалярную функцию вызывают с именем схемы, пропущенный параметр по умолчанию передают словом DEFAULT.
  • SCHEMABINDING защищает от поломки зависимостей и нужен для детерминированных функций в сохраняемых вычисляемых столбцах.
  • По производительности inline TVF обычно безопаснее всего; скалярные функции с SQL Server 2019 могут встраиваться, но is_inlineable = 1 — только пригодность тела, факт встраивания подтверждает фактический план.

Где применяется / связь с практикой

Функции в SQL Server встречаются в отчетах, вычисляемых столбцах, политиках безопасности на уровне строк и в коде, который переиспользуют несколько запросов. Частая задача на работе — найти медленный запрос и понять, что причина в скалярной функции или MSTVF, а затем переписать ее в inline-форму без изменения результата.

Освойте тему на практике

Эти навыки вместе с процедурами, индексами, планами выполнения и транзакциями разбирают на курсе «MS SQL Server Developer». Посмотреть формат обучения можно на открытых уроках Otus.

FAQ

Может ли функция вернуть несколько значений?
Скалярная — нет, только одно. Если нужно несколько величин, верните одну строку с несколькими столбцами из inline TVF и подключите ее через CROSS APPLY.

Можно ли создать временную функцию, как временную таблицу?
Нет, #-функций в SQL Server нет. Для экспериментов создают функцию в тестовой базе и удаляют ее командой DROP FUNCTION IF EXISTS (доступна с SQL Server 2016).

Чем inline TVF отличается от представления?
Логически это представление с параметрами: оптимизатор так же подставляет ее запрос. Разница в том, что в представление нельзя передать параметр, а фильтр задают снаружи через WHERE.

OTUS Журнал