Chapter 13 · Stored Procedures, Functions, Views, Triggers, Dynamic SQL, and CLR Boundaries

Stored Procedures, Parameters, Result Sets, Recompilation, and Ownership Chaining

Design SQL Server stored procedures as explicit APIs with typed parameters, result contracts, plan/recompile behavior, transactions, dependencies, permissions, and ownership chains.

Advanced160–200 minutesprocedure API & ownership-chain labSQL Server 2025 CU7 · 17.0.4065.4Compatibility 170 · Developer/ExpressSSMS 22.8.2 · Last reviewed August 2026

Learning outcomes

ServiceHub has reached the point where several applications, scheduled jobs, and support scripts all need the same “find work orders” behavior. Today each caller embeds slightly different SQL. One application returns a different column order, another forgets the status rule, and a third has direct table permissions that no longer match the security model. The problem is no longer just query syntax. It is an API contract inside the database.

A stored procedure is a named server-side module containing Transact-SQL statements. It can accept input parameters, expose output parameters, return one or more result sets, return an integer status code, participate in transactions, and execute under SQL Server permission rules. The useful mental model is not “saved query.” A procedure is deployable code with a caller contract, a security boundary, compilation state, dependencies, failure semantics, and operational telemetry.

01

Design a procedure contract that separates input parameters, output parameters, result sets and return codes.

02

Observe procedure metadata, dependencies, plan-cache/runtime evidence and recompilation behavior.

03

Use TRY/CATCH, XACT_STATE and XACT_ABORT deliberately when a procedure owns a transaction boundary.

04

Explain same-owner ownership chaining and why EXECUTE permission can be safer than broad table permission.

05

Recognize when RECOMPILE is a scoped diagnostic/remediation tool rather than a default procedure option.

1. Build the API contract before the body

The most maintainable procedures start with an explicit contract: parameter names and SQL types, nullability/default semantics, result-set columns and types, error behavior, transaction ownership, permissions, and versioning expectations. Parameter types should match the indexed columns they filter; otherwise conversion can damage cardinality estimates or SARGability before the procedure body gets interesting.

sql · record engine, compatibility, Query Store and programmability configuration
SELECT SERVERPROPERTY('ProductVersion') AS product_version,       SERVERPROPERTY('ProductUpdateLevel') AS update_level,       SERVERPROPERTY('Edition') AS edition,       SERVERPROPERTY('EngineEdition') AS engine_edition;GOSELECT name,compatibility_level,is_query_store_onFROM sys.databasesWHERE name IN (N'ServiceHubLab',N'ServiceHubProgrammabilityLab');GOSELECT name,value_in_useFROM sys.configurationsWHERE name IN (N'clr enabled',N'clr strict security',N'nested triggers');GO
sql · create the disposable ServiceHub programmability lab
USE master;GOIF DB_ID(N'ServiceHubProgrammabilityLab') IS NULL  CREATE DATABASE ServiceHubProgrammabilityLab;GOALTER DATABASE ServiceHubProgrammabilityLab SET COMPATIBILITY_LEVEL = 170;GOUSE ServiceHubProgrammabilityLab;GOIF SCHEMA_ID(N'ops') IS NULL EXEC(N'CREATE SCHEMA ops AUTHORIZATION dbo;');IF SCHEMA_ID(N'api') IS NULL EXEC(N'CREATE SCHEMA api AUTHORIZATION dbo;');IF SCHEMA_ID(N'lab13') IS NULL EXEC(N'CREATE SCHEMA lab13 AUTHORIZATION dbo;');GOIF OBJECT_ID(N'ops.WorkOrder',N'U') IS NULLBEGIN  CREATE TABLE ops.WorkOrder  (    work_order_id bigint IDENTITY(1001,1) NOT NULL      CONSTRAINT PK_ops_WorkOrder PRIMARY KEY,    customer_code varchar(16) NOT NULL,    region_code char(3) NOT NULL,    status varchar(16) NOT NULL,    priority tinyint NOT NULL,    opened_at datetime2(0) NOT NULL,    closed_at datetime2(0) NULL,    amount decimal(12,2) NOT NULL,    description nvarchar(400) NULL,    CONSTRAINT CK_ops_WorkOrder_status      CHECK (status IN ('OPEN','ASSIGNED','CLOSED','ESCALATED')),    CONSTRAINT CK_ops_WorkOrder_priority CHECK (priority BETWEEN 1 AND 5)  );  ;WITH n AS  (    SELECT TOP (30000)      ROW_NUMBER() OVER (ORDER BY a.object_id,b.object_id) AS n    FROM sys.all_objects AS a CROSS JOIN sys.all_objects AS b  )  INSERT ops.WorkOrder(customer_code,region_code,status,priority,opened_at,closed_at,amount,description)  SELECT CONCAT('CUST-',RIGHT('000000'+CONVERT(varchar(6),n%9000),6)),         CASE n%3 WHEN 0 THEN 'N01' WHEN 1 THEN 'W02' ELSE 'E03' END,         CASE WHEN n%23=0 THEN 'ESCALATED'              WHEN n%5=0 THEN 'CLOSED'              WHEN n%2=0 THEN 'ASSIGNED' ELSE 'OPEN' END,         CONVERT(tinyint,1+n%5),         DATEADD(minute,n,'2026-01-01T00:00:00'),         CASE WHEN n%5=0 THEN DATEADD(minute,n+60,'2026-01-01T00:00:00') END,         CAST(25+(n%25000)/10.0 AS decimal(12,2)),         CONCAT(N'ServiceHub work order ',n)  FROM n;  CREATE INDEX IX_ops_WorkOrder_region_status_opened    ON ops.WorkOrder(region_code,status,opened_at)    INCLUDE(customer_code,priority,amount,closed_at);END;GO
sql · create a procedure with input, output, result set and return code
USE ServiceHubProgrammabilityLab;GOCREATE OR ALTER PROCEDURE api.FindWorkOrders  @region_code char(3) = NULL,  @status varchar(16) = NULL,  @max_rows int = 100,  @rows_returned int OUTPUTASBEGIN  SET NOCOUNT ON;  IF @max_rows NOT BETWEEN 1 AND 500  BEGIN    SET @rows_returned = 0;    RETURN 10;  END;  SELECT TOP (@max_rows)         work_order_id,customer_code,region_code,status,         priority,opened_at,closed_at,amount  FROM ops.WorkOrder  WHERE (@region_code IS NULL OR region_code=@region_code)    AND (@status IS NULL OR status=@status)  ORDER BY opened_at DESC,work_order_id DESC;  SET @rows_returned = @@ROWCOUNT;  RETURN 0;END;GO

The result set is application data. The output parameter is metadata about this invocation. The integer return value is a compact status code, not a substitute for returning data. An API client should not infer success merely because it received a rowset; define what each channel means.

sql · call the procedure and inspect all output channels
DECLARE @rows int,@rc int;EXEC @rc = api.FindWorkOrders  @region_code='N01',  @status='OPEN',  @max_rows=5,  @rows_returned=@rows OUTPUT;SELECT @rc AS return_code,@rows AS rows_returned;GOEXEC sys.sp_describe_first_result_set  @tsql=N'EXEC api.FindWorkOrders @region_code=''N01'',@status=''OPEN'',@max_rows=5,@rows_returned=NULL';GO

2. Plans are reusable state, not part of the contract

On first execution SQL Server compiles statements in the procedure and can cache their execution plans. Subsequent calls can reuse those plans if they remain valid. Reuse depends on far more than the procedure name: schema/metadata changes, statistics-related recompiles, SET options, memory pressure, explicit recompile choices, and optimizer decisions all matter. A caller may rely on the result contract; it must not rely on a specific cached plan surviving.

sql · observe procedure runtime and cached-plan evidence
SELECT OBJECT_SCHEMA_NAME(object_id,database_id) AS schema_name,       OBJECT_NAME(object_id,database_id) AS procedure_name,       cached_time,last_execution_time,execution_count,       total_worker_time,total_elapsed_timeFROM sys.dm_exec_procedure_statsWHERE database_id=DB_ID(N'ServiceHubProgrammabilityLab')  AND object_id=OBJECT_ID(N'api.FindWorkOrders');GOSELECT OBJECT_NAME(d.referencing_id) AS referencing_module,       d.referenced_schema_name,d.referenced_entity_nameFROM sys.sql_expression_dependencies AS dWHERE d.referencing_id=OBJECT_ID(N'api.FindWorkOrders');GO

These DMVs are transient: restart, eviction and recompilation can reset what you see. Query Store is the persistent workload evidence layer covered in Chapter 11. Use cache DMVs for current engine state, not as an audit history.

Wrong approach: add WITH RECOMPILE everywhere

WITH RECOMPILE on a procedure prevents reuse of its plan and forces compilation on each execution. That can help when executions genuinely need radically different plans, but it also adds compile CPU and removes cache reuse. Prefer statement-level OPTION (RECOMPILE) when only one statement needs it, or fix statistics/query/index/parameter-sensitive design when that is the root cause.

3. Transaction ownership and error semantics belong in the API design

A read-only lookup rarely needs an explicit transaction. A procedure that performs a business operation often does. Decide whether the procedure starts and completes its own transaction or expects an existing caller transaction. Blindly issuing ROLLBACK in a nested caller can undo work the procedure did not start.

sql · transaction-safe write procedure using XACT_STATE
CREATE OR ALTER PROCEDURE api.CloseWorkOrder  @work_order_id bigintASBEGIN  SET NOCOUNT ON;  SET XACT_ABORT ON;  DECLARE @started bit=0;  IF @@TRANCOUNT=0  BEGIN    BEGIN TRAN;    SET @started=1;  END;  BEGIN TRY    UPDATE ops.WorkOrder      SET status='CLOSED',closed_at=SYSUTCDATETIME()    WHERE work_order_id=@work_order_id      AND status<>'CLOSED';    IF @@ROWCOUNT=0 THROW 50013,'Work order was not found or is already closed.',1;    IF @started=1 COMMIT;  END TRY  BEGIN CATCH    IF XACT_STATE()=-1 ROLLBACK;    ELSE IF XACT_STATE()=1 AND @started=1 ROLLBACK;    THROW;  END CATCH;END;GO

This pattern is intentionally explicit about whether the procedure owns the transaction. In a broader application architecture you may instead require all transaction control at the service layer. The important requirement is one documented rule, not accidental nesting.

4. Ownership chaining: grant the operation, not the whole table

SQL Server checks permissions along an ownership chain. When a caller executes a module and the module accesses another object with the same owner, SQL Server can skip a second permission check on the referenced object. That enables a narrow pattern: grant EXECUTE on the procedure while withholding direct table access. It does not mean procedures automatically bypass security, and dynamic SQL or broken ownership chains change the behavior.

sql · prove same-owner ownership chaining with a user without login
USE ServiceHubProgrammabilityLab;GOIF USER_ID(N'lab13_api_caller') IS NULL  CREATE USER lab13_api_caller WITHOUT LOGIN;GRANT EXECUTE ON OBJECT::api.FindWorkOrders TO lab13_api_caller;DENY SELECT ON OBJECT::ops.WorkOrder TO lab13_api_caller;GOEXECUTE AS USER=N'lab13_api_caller';SELECT USER_NAME() AS execution_user;-- Direct access should fail:BEGIN TRY SELECT TOP (1) * FROM ops.WorkOrder; END TRYBEGIN CATCH SELECT ERROR_NUMBER() AS direct_error,ERROR_MESSAGE() AS direct_message; END CATCH;-- Same-owner module access succeeds:DECLARE @rows int,@rc int;EXEC @rc=api.FindWorkOrders @region_code='W02',@max_rows=2,@rows_returned=@rows OUTPUT;SELECT @rc AS return_code,@rows AS rows_returned;REVERT;GO

The evidence proves only this chain under this ownership and module definition. Cross-database access, different owners, EXECUTE AS, certificates/module signing, and dynamic SQL have different security implications. Chapter 14 develops those mechanisms in depth.

5. Deployment dependencies and schema changes

Modules are schema objects, so deployment needs dependency awareness and tests. CREATE OR ALTER preserves permissions while updating the definition. Schema changes can invalidate cached plans or make modules fail at execution. SQL Server supports deferred name resolution for some procedure references, so “the procedure was created successfully” is not proof that every referenced object exists and every execution path works.

sql · inspect module definitions and dependency metadata
SELECT s.name AS schema_name,o.name,o.type_desc,m.uses_ansi_nulls,       m.uses_quoted_identifier,m.is_schema_boundFROM sys.objects AS oJOIN sys.schemas AS s ON s.schema_id=o.schema_idJOIN sys.sql_modules AS m ON m.object_id=o.object_idWHERE o.object_id IN (OBJECT_ID(N'api.FindWorkOrders'),OBJECT_ID(N'api.CloseWorkOrder'));GOEXEC sys.sp_refreshsqlmodule N'api.FindWorkOrders';GO

sp_refreshsqlmodule can refresh persistent metadata after underlying type/object changes, but it is not a substitute for automated deployment tests. A release pipeline should compile/deploy modules in dependency order, execute contract tests, verify permissions, and compare Query Store/runtime behavior after release.

Production judgment

Use procedures when you want a durable database API, permission boundary, transaction unit, reusable query bundle, or shared business operation. Keep parameters strongly typed, result contracts explicit, ownership documented, and error semantics predictable. Avoid using procedures as giant scripts that secretly create/drop infrastructure, depend on session state, or return a different schema for every branch unless the callers explicitly support that contract.

Mandatory labs require SQL Server 2025 Developer or Express, compatibility 170, CREATE PROCEDURE/ALTER on the lab schema, and appropriate DMV visibility for runtime diagnostics. No restart, cloud service, cluster, CLR, or paid production license is required.

Check your understanding

  1. Why is a stored procedure better modeled as an API contract than as a saved query?
  2. What does WITH RECOMPILE trade away?
  3. Why should a procedure know whether it started the transaction it may roll back?
  4. How can a user execute a procedure without direct SELECT permission on its table?
  5. Does successful CREATE PROCEDURE prove every referenced object exists and every branch works?
Review the answers

1. It defines typed inputs, output channels, security, error/transaction behavior and deployment dependencies in addition to query text.

2. Plan reuse; each execution must compile, which can increase CPU and latency.

3. Because rolling back a caller-owned transaction can undo unrelated work outside the procedure’s intended boundary.

4. Through a valid same-owner ownership chain (or other explicit security mechanisms such as module signing) while granting EXECUTE on the module.

5. No. Deferred name resolution and unexecuted branches mean deployment and execution tests are still required.

Authoritative references

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only Ethereum or ERC-20 compatible assets to this address.