--- name: azure-functions-sql-bindings description: >- Wires Azure Functions to Azure SQL Database with the SQL input and output bindings and the SQL trigger, including the change tracking the trigger cannot run without and the identity permissions the trigger needs beyond the ones the bindings need. Use when a user asks for "a serverless CRUD API over SQL", to "add a SQL input binding", "write to SQL from a function", "react to inserts and updates", "SQL trigger function", "SqlTrigger", "SqlInput", "SqlOutput", or says "my SQL trigger never fires and there is no error". Also use when an output binding silently updated an existing row instead of inserting, which is the documented upsert behaviour. This is the Azure SQL Database story. Local-container-specific setup is outside this skill; connection reuse across invocations belongs to the per-language connect skills. --- # Azure Functions with the Azure SQL bindings Two bindings and a trigger. The bindings are straightforward. **The trigger has a prerequisite that fails without failing the app**, which is what this skill exists for. Every command and C# block below was measured 2026-09-03 against Core Tools 4.12.0, .NET SDK 8.0.421 and `Microsoft.Azure.Functions.Worker.Extensions.Sql` 3.1.536. Error numbers are from Microsoft Learn. ## What this skill does not own | Question | Skill | |---|---| | Reusing a connection across invocations, pool sizing, retry | `connect-from-dotnet`, `connect-from-python`, `connect-from-typescript-and-node` | | Getting the identity to a working passwordless connection | `entra-id-auth` | | Publishing the app and its infrastructure | `deploy-app-to-azure` | The bindings open their own connections, so that pooling advice is about code you write inside the function. ## Step 0: scaffold and add the extension ```bash func init todo-fn --worker-runtime dotnet-isolated cd todo-fn dotnet add package Microsoft.Azure.Functions.Worker.Extensions.Sql ``` That resolved 3.1.536, clearing the v3.1.284 floor Learn sets for Consumption plans. Two things the tooling does not tell you: **Core Tools 4.12.0 scaffolds `net10.0`.** On .NET SDK 8.0.421 the next restore fails with `error NETSDK1045: The current .NET SDK does not support targeting .NET 10.0`. Set `` to a version your SDK has before you add the package. **`func new` cannot create the SQL functions, though `func templates list` advertises them.** The template name loses its spaces and is then rejected: ```bash func templates list | grep 'SQL Trigger' func new --template "SQL Trigger" --name ToDoTrigger ``` The first prints `SQL Trigger`. The second fails with `Unknown template 'SQLTrigger' (Parameter 'templateName')`, as do `SqlTrigger`, `SQLTrigger` and `SQL Input Binding`. `HTTP trigger` succeeds in the same command, so a working `func new` proves nothing about the SQL templates. Write the attribute by hand. ## The failure this skill exists to prevent The trigger is built on SQL change tracking. At startup the listener asks the database for `CHANGE_TRACKING_MIN_VALID_VERSION()`, which Learn documents as returning null when change tracking is off for the database, the object id is invalid, or permission to the table is insufficient. The listener throws on that null, before it polls once. **What that looks like from outside is nothing.** The host catches it and restarts that one listener with exponential backoff, which Learn documents as startup retries. The app starts, reports healthy, keeps serving every other function. The trigger never fires, and the only evidence is one error line naming the listener rather than the missing feature. So change tracking is step one, not a tuning step. ## Step 1: enable change tracking, in two places Two statements. Enabling it on the database does **not** enable it on any table, and the table statement fails if the database is not enabled first. ```sql ALTER DATABASE CURRENT SET CHANGE_TRACKING = ON (CHANGE_RETENTION = 2 DAYS, AUTO_CLEANUP = ON); ALTER TABLE [dbo].[ToDo] ENABLE CHANGE_TRACKING; ``` `CHANGE_RETENTION` defaults to 2 days and is how far back the trigger catches up; an app stopped longer loses the older changes. `AUTO_CLEANUP = OFF` pauses that removal while diagnosing a stalled trigger. These options cannot be combined with other `ALTER DATABASE` options in one statement, which fails with Msg 22114. **The monitored table must have a primary key.** Without one the second statement fails with `Msg 4997, Cannot enable change tracking on table ''. Change tracking requires a primary key on the table.` This skill printed Msg 22119 for that refusal until 2026-09-06, when it was measured on the Azure SQL Database container at 18.0.226_4_147 and came back 4997. A deployment log grepped for 22119 finds nothing. The cloud number is not verified here, so match on the text if you are reading a log from a real Azure SQL Database. ## Step 2: write the trigger The trigger receives a batch of changes, each carrying the changed item and the operation (`Insert`, `Update`, `Delete`). This compiles as written: ```csharp [Function("ToDoTrigger")] public static void Run( [SqlTrigger("[dbo].[ToDo]", "SqlConnectionString")] IReadOnlyList> changes, FunctionContext context) { foreach (SqlChange change in changes) { context.GetLogger("ToDoTrigger").LogInformation( "{Operation} on {Id}", change.Operation, change.Item.Id); } } ``` Open [references/bindings-by-language.md](references/bindings-by-language.md) before writing this declaration in anything but C#: the attribute name, argument casing and package change per language, and nothing else does. **Changes are batched per row, not per statement.** A row written three times between two polls arrives as one entry, showing the difference between the last processed state and now. Anything needing every intermediate value needs another mechanism. ## Step 3: grant the trigger more than the bindings need `db_datareader` and `db_datawriter` are enough for the input and output bindings. Microsoft's wording is that they **are not sufficient** for the trigger, which reads change tracking and keeps its own state and leases tables in an `az_func` schema it creates if absent. ```sql GRANT CREATE TABLE TO []; GRANT CREATE SCHEMA TO []; GRANT SELECT ON [dbo].[ToDo] TO []; GRANT VIEW CHANGE TRACKING ON [dbo].[ToDo] TO []; GO CREATE SCHEMA az_func; GO GRANT ALTER ON SCHEMA::az_func TO []; GRANT SELECT, INSERT, UPDATE, DELETE ON SCHEMA::az_func TO []; ``` Missing these fails the same way: a listener that will not start, an app that looks fine. ## Step 4: input and output bindings The input binding runs a query or stored procedure and hands the rows to the function. **Parameters are bound, not interpolated**, which keeps a route value out of the SQL text: ```csharp [SqlInput( commandText: "SELECT [Id], [title] FROM dbo.ToDo WHERE Id = @Id", commandType: System.Data.CommandType.Text, parameters: "@Id={Query.id}", connectionStringSetting: "SqlConnectionString")] IEnumerable todo ``` `parameters` is one string of the form `@a=1,@b=2`. No name or value may contain a comma or an equals sign, a real limit binding free text. The output binding takes an object or a collection and a table name: ```csharp [SqlOutput("dbo.ToDo", connectionStringSetting: "SqlConnectionString")] public ToDoItem ToDoItem { get; set; } ``` **It upserts.** Learn states the output bindings use T-SQL `MERGE`, so a write whose key already exists updates that row rather than failing, and an agent treating the binding as an insert will report a create that was an overwrite. `MERGE` also needs `SELECT` on the target, not only `INSERT`. Two limits. Columns typed `NTEXT`, `TEXT` or `IMAGE` are unsupported and the upsert fails, because they are incompatible with the `OPENJSON` the binding builds its `MERGE` from. And an exception in the binding stops the function, so an HTTP trigger returns 500 unless `IAsyncCollector` is used and `FlushAsync` awaited inside a try block. ## Step 5: point it at Azure SQL Database, passwordless `connectionStringSetting` names an **application setting**, not a connection string. Locally it lives in `local.settings.json`, which never goes into source control. In Azure it is an app setting whose value carries an identity: ```text Server=.database.windows.net; Authentication=Active Directory Default; Database=; User Id= ``` Learn's managed identity tutorial states `Active Directory Default` resolves to developer credentials locally and the managed identity in Azure, so one string serves both, and that `User Id` is omitted for a system-assigned identity. The bindings overview page shows `Active Directory Managed Identity` instead; both are valid, only the first also works locally. The identity needs a database user: ```sql CREATE USER [] FROM EXTERNAL PROVIDER; ALTER ROLE db_datareader ADD MEMBER []; ALTER ROLE db_datawriter ADD MEMBER []; GO ``` `` is the identity's own name for a user-assigned identity, the function app's name for a system-assigned one. If a trigger is in play, add the grants from step 3 on top. ## Step 6: tune the loop and let it scale `MaxBatchSize` defaults to 100, `PollingIntervalMs` to 1000 and `MaxChangesPerWorker` to 1000, all under `extensions.Sql` in `host.json`. On a Premium plan, scaling out on pending change count needs runtime scale monitoring: ```bash az resource update -g -n /config/web \ --set properties.functionsRuntimeScaleMonitoringEnabled=1 \ --resource-type Microsoft.Web/sites ``` A batch that throws is retried after 60 seconds; a row failing five times in a row is ignored for all future changes. A poison row is skipped, not queued behind, so log the key on failure or the loss is invisible. ## Check it worked Change tracking is provable from SQL alone, before the app runs. Two rows back, one per query, or the trigger does not start: ```bash sqlcmd -S .database.windows.net -d -G -Q \ "SELECT DB_NAME() AS db, is_auto_cleanup_on, retention_period, retention_period_units_desc FROM sys.change_tracking_databases WHERE database_id = DB_ID(); SELECT OBJECT_NAME(object_id) AS tracked_table FROM sys.change_tracking_tables;" ``` An empty second result set is the common failure: the database statement looks done. Then confirm what the listener reads, a number rather than `NULL` once both statements have run: ```sql SELECT CHANGE_TRACKING_MIN_VALID_VERSION(OBJECT_ID('dbo.ToDo')) AS min_valid_version; ``` Only then read the host log: `Started SQL trigger listener for table` is the success line, and `Failed to start SQL trigger listener` on a healthy app is this skill's subject. Finally, write the same primary key twice through the output binding. Two 201 responses and one row confirms the `MERGE`. ## Do not - Do not report a trigger as working because the function app started. It starts either way. - Do not enable change tracking on the database and stop. The table statement is separate, and is the one usually missed. - Do not grant only `db_datareader` and `db_datawriter` to a trigger identity. - Do not create the `az_func` tables by hand or rename them. The trigger owns them. - Do not treat the output binding as an insert. It is a `MERGE` on the primary key. - Do not reach for `func new` here, or read a name in `func templates list` as proof it scaffolds. - Do not open your own connection per invocation. That belongs to the connect skills. ## References - [references/bindings-by-language.md](references/bindings-by-language.md): read it when the target language is not C#, or to look up the bundle and package a language needs. - [Azure SQL bindings for Functions](https://learn.microsoft.com/azure/azure-functions/functions-bindings-azure-sql): the authority. Fetch it rather than recalling it. - [Azure SQL trigger for Functions](https://learn.microsoft.com/azure/azure-functions/functions-bindings-azure-sql-trigger): fetch it for the host.json and local.settings.json tables and the retry rules. - [About change tracking](https://learn.microsoft.com/sql/relational-databases/track-changes/about-change-tracking-sql-server): retention, cleanup and what the change tables hold.